diff --git a/app/routes/prisma-studio.$.tsx b/app/routes/prisma-studio.$.tsx
new file mode 100644
index 000000000..b78be5be0
--- /dev/null
+++ b/app/routes/prisma-studio.$.tsx
@@ -0,0 +1,22 @@
+import type {DataFunctionArgs} from '@remix-run/node'
+import {requireAdminUser} from '~/utils/session.server'
+
+export async function loader({request}: DataFunctionArgs) {
+ await requireAdminUser(request)
+ const {pathname} = new URL(request.url)
+ const url = `http://localhost:5555${pathname.replace('/prisma-studio', '')}`
+ return fetch(url, {
+ headers: request.headers,
+ })
+}
+
+export async function action({request}: DataFunctionArgs) {
+ await requireAdminUser(request)
+ const {pathname} = new URL(request.url)
+ const url = `http://localhost:5555${pathname.replace('/prisma-studio', '')}`
+ return fetch(url, {
+ method: request.method,
+ body: request.body,
+ headers: request.headers,
+ })
+}
diff --git a/app/routes/prisma-studio.tsx b/app/routes/prisma-studio.tsx
new file mode 100644
index 000000000..7ea0fb0d6
--- /dev/null
+++ b/app/routes/prisma-studio.tsx
@@ -0,0 +1,39 @@
+import {spawn} from 'child_process'
+import type {DataFunctionArgs} from '@remix-run/node'
+import {requireAdminUser} from '~/utils/session.server'
+
+async function ensurePrismaStudioIsRunning() {
+ try {
+ await fetch('http://localhost:5555', {method: 'HEAD'})
+ // eslint-disable-next-line @typescript-eslint/no-implicit-any-catch, @typescript-eslint/no-explicit-any
+ } catch (error: any) {
+ if ('code' in error) {
+ if (error.code !== 'ECONNREFUSED') throw error
+ }
+
+ spawn('npx', ['prisma', 'studio'], {
+ stdio: 'inherit',
+ shell: true,
+ detached: true,
+ })
+ // give it a second to start up
+ await new Promise(resolve => setTimeout(resolve, 1000))
+ }
+}
+
+export async function loader({request}: DataFunctionArgs) {
+ await requireAdminUser(request)
+ await ensurePrismaStudioIsRunning()
+ const response = await fetch('http://localhost:5555', request)
+ const studioHtml = await response.text()
+ const relativeStudioHtml = studioHtml.replace(
+ /"\.\/(.*)"/g,
+ '"/prisma-studio/$1"',
+ )
+ return new Response(relativeStudioHtml, {
+ headers: {
+ 'Content-Type': 'text/html',
+ 'Content-Length': String(Buffer.byteLength(relativeStudioHtml)),
+ },
+ })
+}
diff --git a/app/routes/refresh-commit-sha[.]json.tsx b/app/routes/refresh-commit-sha[.]json.tsx
index ec521f5cb..134f8b28e 100644
--- a/app/routes/refresh-commit-sha[.]json.tsx
+++ b/app/routes/refresh-commit-sha[.]json.tsx
@@ -1,14 +1,28 @@
-import type {LoaderFunction} from '@remix-run/node'
-import {redisCache} from '~/utils/redis.server'
-import {commitShaKey as refreshCacheCommitShaKey} from './action/refresh-cache'
+import {json} from '@remix-run/node'
+import {cache} from '~/utils/cache.server'
+import type {RefreshShaInfo} from './action/refresh-cache'
+import {
+ commitShaKey as refreshCacheCommitShaKey,
+ isRefreshShaInfo,
+} from './action/refresh-cache'
-export const loader: LoaderFunction = async () => {
- const shaInfo = await redisCache.get(refreshCacheCommitShaKey)
- const data = JSON.stringify(shaInfo)
- return new Response(data, {
- headers: {
- 'Content-Type': 'application/json',
- 'Content-Length': String(Buffer.byteLength(data)),
- },
- })
+export async function loader() {
+ const result = await cache.get(refreshCacheCommitShaKey)
+ if (!result) {
+ return json(null)
+ }
+
+ let value: RefreshShaInfo
+ try {
+ value = JSON.parse(result.value as any)
+ if (!isRefreshShaInfo(value)) {
+ throw new Error(`Invalid value: ${result.value}`)
+ }
+ } catch (error: unknown) {
+ console.error(`Error parsing commit sha from cache: ${error}`)
+ cache.delete(refreshCacheCommitShaKey)
+ return json(null)
+ }
+
+ return json(value)
}
diff --git a/app/routes/resources/cache.$cacheKey.ts b/app/routes/resources/cache.$cacheKey.ts
new file mode 100644
index 000000000..549b9a528
--- /dev/null
+++ b/app/routes/resources/cache.$cacheKey.ts
@@ -0,0 +1,12 @@
+import type {DataFunctionArgs} from '@remix-run/node'
+import {json} from '@remix-run/node'
+import invariant from 'tiny-invariant'
+import {cache} from '~/utils/cache.server'
+import {requireAdminUser} from '~/utils/session.server'
+
+export async function loader({request, params}: DataFunctionArgs) {
+ await requireAdminUser(request)
+ const {cacheKey} = params
+ invariant(cacheKey, 'cacheKey is required')
+ return json({cacheKey, value: await cache.get(cacheKey)})
+}
diff --git a/app/routes/resources/search.ts b/app/routes/resources/search.ts
index 06fbf0f84..a8baddfbd 100644
--- a/app/routes/resources/search.ts
+++ b/app/routes/resources/search.ts
@@ -1,9 +1,9 @@
-import type {LoaderArgs} from '@remix-run/node'
+import type {DataFunctionArgs} from '@remix-run/node'
import {json} from '@remix-run/node'
import {getDomainUrl} from '~/utils/misc'
import {searchKCD} from '~/utils/search.server'
-export async function loader({request}: LoaderArgs) {
+export async function loader({request}: DataFunctionArgs) {
const query = new URL(request.url).searchParams.get('query')
const domainUrl = getDomainUrl(request)
if (typeof query !== 'string' || !query) {
diff --git a/app/routes/s.$query.tsx b/app/routes/s.$query.tsx
index 4db60421e..2f0cce463 100644
--- a/app/routes/s.$query.tsx
+++ b/app/routes/s.$query.tsx
@@ -1,5 +1,5 @@
import * as React from 'react'
-import type {LoaderArgs} from '@remix-run/node'
+import type {DataFunctionArgs} from '@remix-run/node'
import {json, redirect} from '@remix-run/node'
import {Link, useLoaderData, useParams} from '@remix-run/react'
import {images} from '~/images'
@@ -43,7 +43,7 @@ function itemsToSegmentedItems(items: NormalizedItemGroup['items']) {
}, init)
}
-export async function loader({request, params}: LoaderArgs) {
+export async function loader({request, params}: DataFunctionArgs) {
const query = params.query
if (typeof query !== 'string' || !query) return redirect('/')
diff --git a/app/routes/signup.tsx b/app/routes/signup.tsx
index 1f32f60d6..d2a81e40e 100644
--- a/app/routes/signup.tsx
+++ b/app/routes/signup.tsx
@@ -8,7 +8,7 @@ import type {KCDHandle, Team} from '~/types'
import {useTeam} from '~/utils/team-provider'
import {getSession, getUser} from '~/utils/session.server'
import {getLoginInfoSession} from '~/utils/login.server'
-import {prismaWrite, prismaRead, validateMagicLink} from '~/utils/prisma.server'
+import {prisma, validateMagicLink} from '~/utils/prisma.server'
import {getErrorStack, isTeam, teams} from '~/utils/misc'
import {tagKCDSiteSubscriber} from '../convertkit/convertkit.server'
import {Grid} from '~/components/grid'
@@ -104,7 +104,7 @@ export const action: ActionFunction = async ({request}) => {
const {firstName, team} = formData
try {
- const user = await prismaWrite.user.create({
+ const user = await prisma.user.create({
data: {email, firstName, team},
})
@@ -114,7 +114,7 @@ export const action: ActionFunction = async ({request}) => {
firstName,
fields: {kcd_team: team, kcd_site_id: user.id},
})
- await prismaWrite.user.update({
+ await prisma.user.update({
data: {convertKitId: String(sub.id)},
where: {id: user.id},
})
@@ -155,7 +155,7 @@ export const loader: LoaderFunction = async ({request}) => {
})
}
- const userForMagicLink = await prismaRead.user.findFirst({
+ const userForMagicLink = await prisma.user.findFirst({
where: {email},
select: {id: true},
})
diff --git a/app/routes/workshops.tsx b/app/routes/workshops.tsx
index 72495a75f..b493d8002 100644
--- a/app/routes/workshops.tsx
+++ b/app/routes/workshops.tsx
@@ -4,8 +4,6 @@ import {json} from '@remix-run/node'
import {Outlet} from '@remix-run/react'
import type {KCDHandle, Workshop} from '~/types'
import {getWorkshops} from '~/utils/workshops.server'
-import type {Timings} from '~/utils/metrics.server'
-import {getServerTimeHeader} from '~/utils/metrics.server'
import type {WorkshopEvent} from '~/utils/workshop-tickets.server'
import {getScheduledEvents} from '~/utils/workshop-tickets.server'
import {reuseUsefulLoaderHeaders} from '~/utils/misc'
@@ -22,9 +20,8 @@ type LoaderData = {
}
export const loader: LoaderFunction = async ({request}) => {
- const timings: Timings = {}
const [workshops, workshopEvents] = await Promise.all([
- getWorkshops({request, timings}),
+ getWorkshops({request}),
getScheduledEvents({request}),
])
@@ -43,7 +40,6 @@ export const loader: LoaderFunction = async ({request}) => {
const headers = {
'Cache-Control': 'public, max-age=3600',
Vary: 'Cookie',
- 'Server-Timing': getServerTimeHeader(timings),
}
return json(data, {headers})
}
diff --git a/app/routes/workshops/$slug.tsx b/app/routes/workshops/$slug.tsx
index 068f4f99f..a43f2a05f 100644
--- a/app/routes/workshops/$slug.tsx
+++ b/app/routes/workshops/$slug.tsx
@@ -17,8 +17,6 @@ import {Spacer} from '~/components/spacer'
import {TestimonialSection} from '~/components/sections/testimonial-section'
import {FourOhFour} from '~/components/errors'
import {getBlogRecommendations} from '~/utils/blog.server'
-import type {Timings} from '~/utils/metrics.server'
-import {getServerTimeHeader} from '~/utils/metrics.server'
import {getWorkshops} from '~/utils/workshops.server'
import {useWorkshopsData} from '../workshops'
import {ConvertKitForm} from '../../convertkit/form'
@@ -61,16 +59,14 @@ export const loader: LoaderFunction = async ({params, request}) => {
if (!params.slug) {
throw new Error('params.slug is not defined')
}
- const timings: Timings = {}
const [workshops, blogRecommendations] = await Promise.all([
- getWorkshops({request, timings}),
+ getWorkshops({request}),
getBlogRecommendations(request),
])
const workshop = workshops.find(w => w.slug === params.slug)
const headers = {
'Cache-Control': 'private, max-age=3600',
Vary: 'Cookie',
- 'Server-Timing': getServerTimeHeader(timings),
}
if (!workshop) {
diff --git a/app/utils/blog.server.ts b/app/utils/blog.server.ts
index f9521f601..e601af3d7 100644
--- a/app/utils/blog.server.ts
+++ b/app/utils/blog.server.ts
@@ -1,8 +1,9 @@
import type {Team, MdxListItem, Await, User} from '~/types'
import {subYears, subMonths} from 'date-fns'
+import {cachified} from 'cachified'
import {shuffle} from 'lodash'
import {getBlogMdxListItems} from './mdx'
-import {prismaRead} from './prisma.server'
+import {prisma} from './prisma.server'
import {
getDomainUrl,
getOptionalTeam,
@@ -13,8 +14,7 @@ import {
import {getSession, getUser} from './session.server'
import {filterPosts} from './blog'
import {getClientSession} from './client.server'
-import {cachified, lruCache} from './cache.server'
-import {redisCache} from './redis.server'
+import {cache, lruCache, shouldForceFresh} from './cache.server'
import {sendMessageFromDiscordBot} from './discord.server'
import {teamEmoji} from './team-provider'
@@ -51,7 +51,7 @@ async function getBlogRecommendations(
const where = user
? {user: {id: user.id}, postSlug: {notIn: exclude.filter(Boolean)}}
: {clientId, postSlug: {notIn: exclude.filter(Boolean)}}
- const readPosts = await prismaRead.postRead.groupBy({
+ const readPosts = await prisma.postRead.groupBy({
by: ['postSlug'],
where,
})
@@ -125,7 +125,7 @@ async function getMostPopularPostSlugs({
if (exclude.length) return getFreshValue()
async function getFreshValue() {
- const result = await prismaRead.postRead.groupBy({
+ const result = await prisma.postRead.groupBy({
by: ['postSlug'],
_count: true,
orderBy: {
@@ -144,7 +144,8 @@ async function getMostPopularPostSlugs({
return cachified({
key: `${limit}-most-popular-post-slugs`,
- maxAge: 1000 * 60,
+ ttl: 1000 * 60,
+ staleWhileRevalidate: 1000 * 60 * 60 * 24,
cache: lruCache,
getFreshValue,
checkValue: (value: unknown) =>
@@ -153,30 +154,34 @@ async function getMostPopularPostSlugs({
}
async function getTotalPostReads(request: Request, slug?: string) {
+ const key = `total-post-reads:${slug ?? '__all-posts__'}`
return cachified({
- key: `total-post-reads:${slug ?? '__all-posts__'}`,
+ key,
cache: lruCache,
- maxAge: 1000 * 60,
- request,
+ ttl: 1000 * 60,
+ staleWhileRevalidate: 1000 * 60 * 60 * 24,
+ forceFresh: await shouldForceFresh({request, key}),
checkValue: (value: unknown) => typeof value === 'number',
getFreshValue: () =>
- prismaRead.postRead.count(slug ? {where: {postSlug: slug}} : undefined),
+ prisma.postRead.count(slug ? {where: {postSlug: slug}} : undefined),
})
}
async function getReaderCount(request: Request) {
+ const key = 'total-reader-count'
return cachified({
- key: 'total-reader-count',
+ key,
cache: lruCache,
- maxAge: 1000 * 60 * 5,
- request,
+ ttl: 1000 * 60 * 5,
+ staleWhileRevalidate: 1000 * 60 * 60 * 24,
+ forceFresh: await shouldForceFresh({request, key}),
checkValue: (value: unknown) => typeof value === 'number',
getFreshValue: async () => {
// couldn't figure out how to do this in one query with out $queryRaw ๐คทโโ๏ธ
type CountResult = [{count: BigInt}]
const [userIdCount, clientIdCount] = await Promise.all([
- prismaRead.$queryRaw`SELECT COUNT(DISTINCT "public"."PostRead"."userId") FROM "public"."PostRead" WHERE ("public"."PostRead"."userId") IS NOT NULL` as Promise
,
- prismaRead.$queryRaw`SELECT COUNT(DISTINCT "public"."PostRead"."clientId") FROM "public"."PostRead" WHERE ("public"."PostRead"."clientId") IS NOT NULL` as Promise,
+ prisma.$queryRaw`SELECT COUNT(DISTINCT "public"."PostRead"."userId") FROM "public"."PostRead" WHERE ("public"."PostRead"."userId") IS NOT NULL` as Promise,
+ prisma.$queryRaw`SELECT COUNT(DISTINCT "public"."PostRead"."clientId") FROM "public"."PostRead" WHERE ("public"."PostRead"."clientId") IS NOT NULL` as Promise,
]).catch(() => [[{count: BigInt(0)}], [{count: BigInt(0)}]])
return Number(userIdCount[0].count) + Number(clientIdCount[0].count)
},
@@ -197,10 +202,10 @@ async function getBlogReadRankings({
const key = slug ? `blog:${slug}:rankings` : `blog:rankings`
const rankingObjs = await cachified({
key,
- cache: redisCache,
- maxAge: slug ? 1000 * 60 * 60 * 24 * 7 : 1000 * 60 * 60,
- request,
- forceFresh,
+ cache,
+ ttl: slug ? 1000 * 60 * 60 * 24 * 7 : 1000 * 60 * 60,
+ staleWhileRevalidate: 1000 * 60 * 60 * 24,
+ forceFresh: await shouldForceFresh({forceFresh, request, key}),
checkValue: (value: unknown) =>
Array.isArray(value) &&
value.every(v => typeof v === 'object' && 'team' in v),
@@ -209,7 +214,7 @@ async function getBlogReadRankings({
teams.map(async function getRankingsForTeam(
team,
): Promise<{team: Team; totalReads: number; ranking: number}> {
- const totalReads = await prismaRead.postRead.count({
+ const totalReads = await prisma.postRead.count({
where: {
postSlug: slug,
user: {team},
@@ -263,20 +268,21 @@ async function getAllBlogPostReadRankings({
request?: Request
forceFresh?: boolean
}) {
+ const key = 'all-blog-post-read-rankings'
return cachified({
- key: 'all-blog-post-read-rankings',
- cache: redisCache,
- forceFresh,
- request,
- maxAge: 1000 * 60 * 5, // the underlying caching should be able to handle this every 5 minues
+ key,
+ cache,
+ forceFresh: await shouldForceFresh({forceFresh, request, key}),
+ ttl: 1000 * 60 * 5, // the underlying caching should be able to handle this every 5 minues
+ staleWhileRevalidate: 1000 * 60 * 60 * 24,
getFreshValue: async () => {
const posts = await getBlogMdxListItems({request})
const {default: pLimit} = await import('p-limit')
// each of the getBlogReadRankings calls results in 9 postgres queries
// and we don't want to hit the limit of connections so we limit this
- // to 2 at a time. Though most of the data should be cached in redis
- // anyway. This is good to just be certain.
+ // to 2 at a time. Though most of the data should be cached anyway.
+ // This is good to just be certain.
const limit = pLimit(2)
const allPostReadRankings: Record = {}
await Promise.all(
@@ -297,7 +303,7 @@ async function getAllBlogPostReadRankings({
async function getRecentReads(slug: string | undefined, team: Team) {
const withinTheLastSixMonths = subMonths(new Date(), 6)
- const count = await prismaRead.postRead.count({
+ const count = await prisma.postRead.count({
where: {
postSlug: slug,
createdAt: {gt: withinTheLastSixMonths},
@@ -310,7 +316,7 @@ async function getRecentReads(slug: string | undefined, team: Team) {
async function getActiveMembers(team: Team) {
const withinTheLastYear = subYears(new Date(), 1)
- const count = await prismaRead.user.count({
+ const count = await prisma.user.count({
where: {
team,
postReads: {
@@ -327,7 +333,7 @@ async function getActiveMembers(team: Team) {
async function getSlugReadsByUser(request: Request) {
const user = await getUser(request)
if (!user) return []
- const reads = await prismaRead.postRead.findMany({
+ const reads = await prisma.postRead.findMany({
where: {userId: user.id},
select: {postSlug: true},
})
diff --git a/app/utils/cache.server.ts b/app/utils/cache.server.ts
index 3a0ced5ad..baac80590 100644
--- a/app/utils/cache.server.ts
+++ b/app/utils/cache.server.ts
@@ -1,237 +1,97 @@
import LRU from 'lru-cache'
-import {formatDuration, intervalToDuration} from 'date-fns'
-import type {Timings} from './metrics.server'
-import {time} from './metrics.server'
+import type {Cache as CachifiedCache, CacheEntry} from 'cachified'
+import {lruCacheAdapter} from 'cachified'
+import Database from 'better-sqlite3'
import {getUser} from './session.server'
+import {getRequiredServerEnvVar} from './misc'
-function niceFormatDuration(milliseconds: number) {
- const duration = intervalToDuration({start: 0, end: milliseconds})
- const formatted = formatDuration(duration, {delimiter: ', '})
- const ms = milliseconds % 1000
- return [formatted, ms ? `${ms.toFixed(3)}ms` : null]
- .filter(Boolean)
- .join(', ')
-}
+const CACHE_DATABASE_PATH = getRequiredServerEnvVar('CACHE_DATABASE_PATH')
declare global {
// This preserves the LRU cache during development
// eslint-disable-next-line
- var lruCache:
- | (LRU & {name: string})
- | undefined
+ var __lruCache: LRU> | undefined,
+ __cacheDb: ReturnType | undefined
}
-const lruCache = (global.lruCache = global.lruCache
- ? global.lruCache
- : createLruCache())
-
-function createLruCache() {
- // doing anything other than "any" here was a big pain
- const newCache = new LRU({
- max: 1000,
- ttl: 1000 * 60 * 60, // 1 hour
- })
- Object.assign(newCache, {name: 'LRU'})
- return newCache as typeof newCache & {name: 'LRU'}
+const cacheDb = (global.__cacheDb = global.__cacheDb
+ ? global.__cacheDb
+ : createDatabase())
+
+function createDatabase() {
+ const db = new Database(CACHE_DATABASE_PATH)
+ // create cache table with metadata JSON column and value JSON column if it does not exist already
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS cache (
+ key TEXT PRIMARY KEY,
+ metadata TEXT,
+ value TEXT
+ )
+ `)
+ return db
}
-type CacheMetadata = {
- createdTime: number
- maxAge: number | null
+const lru = (global.__lruCache = global.__lruCache
+ ? global.__lruCache
+ : new LRU>({max: 1000}))
+
+export const lruCache = lruCacheAdapter(lru)
+
+export const cache: CachifiedCache = {
+ name: 'SQLite cache',
+ get(key) {
+ const result = cacheDb
+ .prepare('SELECT value, metadata FROM cache WHERE key = ?')
+ .get(key)
+ if (!result) return null
+ return {
+ metadata: JSON.parse(result.metadata),
+ value: JSON.parse(result.value),
+ }
+ },
+ set(key, {value, metadata}) {
+ cacheDb
+ .prepare(
+ 'INSERT OR REPLACE INTO cache (key, value, metadata) VALUES (@key, @value, @metadata)',
+ )
+ .run({
+ key,
+ value: JSON.stringify(value),
+ metadata: JSON.stringify(metadata),
+ })
+ },
+ async delete(key) {
+ cacheDb.prepare('DELETE FROM cache WHERE key = ?').run(key)
+ },
}
-function shouldRefresh(metadata: CacheMetadata) {
- if (metadata.maxAge) {
- return Date.now() > metadata.createdTime + metadata.maxAge
- }
- return false
+export async function getAllCacheKeys(limit: number) {
+ return cacheDb
+ .prepare('SELECT key FROM cache LIMIT ?')
+ .all(limit)
+ .map(row => row.key)
}
-type VNUP = Value | null | undefined | Promise
-
-const keysRefreshing = new Set()
+export async function searchCacheKeys(search: string, limit: number) {
+ return cacheDb
+ .prepare('SELECT key FROM cache WHERE key LIKE ? LIMIT ?')
+ .all(`%${search}%`, limit)
+ .map(row => row.key)
+}
-async function cachified<
- Value,
- Cache extends {
- name: string
- get: (key: string) => VNUP<{
- metadata: CacheMetadata
- value: Value
- }>
- set: (
- key: string,
- value: {
- metadata: CacheMetadata
- value: Value
- },
- ) => unknown | Promise
- del: (key: string) => unknown | Promise
- },
->(options: {
- key: string
- cache: Cache
- getFreshValue: () => Promise
- checkValue?: (value: Value) => boolean | string
+export async function shouldForceFresh({
+ forceFresh,
+ request,
+ key,
+}: {
forceFresh?: boolean | string
request?: Request
- fallbackToCache?: boolean
- timings?: Timings
- timingType?: string
- maxAge?: number
-}): Promise {
- const {
- key,
- cache,
- getFreshValue,
- request,
- checkValue = value => Boolean(value),
- fallbackToCache = true,
- timings,
- timingType = 'getting fresh value',
- maxAge,
- } = options
-
- // if forceFresh is a string, we'll only force fresh if the key is in the
- // comma separated list. Otherwise we'll go with it's value and fallback
- // to the shouldForceFresh function on the request if the request is provided
- // otherwise it's false.
- const forceFresh =
- typeof options.forceFresh === 'string'
- ? options.forceFresh.split(',').includes(key)
- : options.forceFresh ??
- (request ? await shouldForceFresh(request, key) : false)
-
- function assertCacheEntry(entry: unknown): asserts entry is {
- metadata: CacheMetadata
- value: Value
- } {
- if (typeof entry !== 'object' || entry === null) {
- throw new Error(
- `Cache entry for ${key} is not a cache entry object, it's a ${typeof entry}`,
- )
- }
- if (!('metadata' in entry)) {
- throw new Error(
- `Cache entry for ${key} does not have a metadata property`,
- )
- }
- if (!('value' in entry)) {
- throw new Error(`Cache entry for ${key} does not have a value property`)
- }
- }
-
- if (!forceFresh) {
- try {
- const cached = await time({
- name: `cache.get(${key})`,
- type: 'cache read',
- fn: () => cache.get(key),
- timings,
- })
- if (cached) {
- assertCacheEntry(cached)
-
- if (shouldRefresh(cached.metadata)) {
- // time to refresh the value. Fire and forget so we don't slow down
- // this request
- // we use setTimeout here to make sure this happens on the next tick
- // of the event loop so we don't end up slowing this request down in the
- // event the cache is synchronous (unlikely now, but if the code is changed
- // then it's quite possible this could happen and it would be easy to
- // forget to check).
- // In practice we have had a handful of situations where multiple
- // requests triggered a refresh of the same resource, so that's what
- // the keysRefreshing thing is for to ensure we don't refresh a
- // value if it's already in the process of being refreshed.
- if (!keysRefreshing.has(key)) {
- keysRefreshing.add(key)
- setTimeout(() => {
- // eslint-disable-next-line prefer-object-spread
- void cachified(Object.assign({}, options, {forceFresh: true}))
- .catch(() => {})
- .finally(() => {
- keysRefreshing.delete(key)
- })
- }, 200)
- }
- }
- const valueCheck = checkValue(cached.value)
- if (valueCheck === true) {
- return cached.value
- } else {
- const reason = typeof valueCheck === 'string' ? valueCheck : 'unknown'
- console.warn(
- `check failed for cached value of ${key}\nReason: ${reason}.\nDeleting the cache key and trying to get a fresh value.`,
- cached,
- )
- await cache.del(key)
- }
- }
- } catch (error: unknown) {
- console.error(
- `error with cache at ${key}. Deleting the cache key and trying to get a fresh value.`,
- error,
- )
- await cache.del(key)
- }
- }
-
- const start = performance.now()
- const value = await time({
- name: `getFreshValue for ${key}`,
- type: timingType,
- fn: getFreshValue,
- timings,
- }).catch((error: unknown) => {
- console.error(
- `getting a fresh value for ${key} failed`,
- {fallbackToCache, forceFresh},
- error,
- )
- // If we got this far without forceFresh then we know there's nothing
- // in the cache so no need to bother trying again without a forceFresh.
- // So we need both the option to fallback and the ability to fallback.
- if (fallbackToCache && forceFresh) {
- return cachified({...options, forceFresh: false})
- } else {
- throw error
- }
- })
- const totalTime = performance.now() - start
-
- const valueCheck = checkValue(value)
- if (valueCheck === true) {
- const metadata: CacheMetadata = {
- maxAge: maxAge ?? null,
- createdTime: Date.now(),
- }
- try {
- console.log(
- `Updating the cache value for ${key}.`,
- `Getting a fresh value for this took ${niceFormatDuration(totalTime)}.`,
- `Caching for a minimum of ${
- typeof maxAge === 'number'
- ? `${niceFormatDuration(maxAge)}`
- : 'forever'
- } in ${cache.name}.`,
- )
- await cache.set(key, {metadata, value})
- } catch (error: unknown) {
- console.error(`error setting cache: ${key}`, error)
- }
- } else {
- const reason = typeof valueCheck === 'string' ? valueCheck : 'unknown'
- console.error(
- `check failed for cached value of ${key}\nReason: ${reason}.\nDeleting the cache key and trying to get a fresh value.`,
- value,
- )
- throw new Error(`check failed for fresh value of ${key}`)
- }
- return value
-}
+ key: string
+}) {
+ if (typeof forceFresh === 'boolean') return forceFresh
+ if (typeof forceFresh === 'string') return forceFresh.split(',').includes(key)
-async function shouldForceFresh(request: Request, key: string) {
+ if (!request) return false
const fresh = new URL(request.url).searchParams.get('fresh')
if (typeof fresh !== 'string') return false
if ((await getUser(request))?.role !== 'ADMIN') return false
@@ -240,8 +100,6 @@ async function shouldForceFresh(request: Request, key: string) {
return fresh.split(',').includes(key)
}
-export {cachified, lruCache}
-
/*
eslint
max-depth: "off",
diff --git a/app/utils/compile-mdx.server.ts b/app/utils/compile-mdx.server.ts
index c8e1f4dd5..653a3abad 100644
--- a/app/utils/compile-mdx.server.ts
+++ b/app/utils/compile-mdx.server.ts
@@ -12,7 +12,7 @@ import type {GitHubFile} from '~/types'
import * as twitter from './twitter.server'
function handleEmbedderError({url}: {url: string}) {
- return `Error embedding ${url}.`
+ return `
Error embedding ${url}
.`
}
type GottenHTML = string | null
@@ -250,7 +250,11 @@ async function getQueue() {
const {default: PQueue} = await import('p-queue')
if (_queue) return _queue
- _queue = new PQueue({concurrency: 1})
+ _queue = new PQueue({
+ concurrency: 1,
+ throwOnTimeout: true,
+ timeout: 1000 * 30,
+ })
return _queue
}
diff --git a/app/utils/credits.server.ts b/app/utils/credits.server.ts
index bbd38b1ba..a0b97c423 100644
--- a/app/utils/credits.server.ts
+++ b/app/utils/credits.server.ts
@@ -1,8 +1,8 @@
import * as YAML from 'yaml'
import {downloadFile} from './github.server'
import {getErrorMessage, typedBoolean} from './misc'
-import {redisCache} from './redis.server'
-import {cachified} from './cache.server'
+import {cachified} from 'cachified'
+import {cache, shouldForceFresh} from './cache.server'
export type Person = {
name: string
@@ -120,12 +120,13 @@ async function getPeople({
request?: Request
forceFresh?: boolean
}) {
+ const key = 'content:data:credits.yml'
const allPeople = await cachified({
- cache: redisCache,
- key: 'content:data:credits.yml',
- request,
- forceFresh,
- maxAge: 1000 * 60 * 60 * 24 * 30,
+ cache,
+ key,
+ forceFresh: await shouldForceFresh({forceFresh, request, key}),
+ ttl: 1000 * 60 * 60 * 24 * 30,
+ staleWhileRevalidate: 1000 * 60 * 60 * 24,
getFreshValue: async () => {
const creditsString = await downloadFile('content/data/credits.yml')
const rawCredits = YAML.parse(creditsString)
diff --git a/app/utils/discord.server.ts b/app/utils/discord.server.ts
index 21741f8d6..158028bfe 100644
--- a/app/utils/discord.server.ts
+++ b/app/utils/discord.server.ts
@@ -1,6 +1,7 @@
import type {User, Team} from '~/types'
-import {prismaWrite} from './prisma.server'
+import {prisma} from './prisma.server'
import {getRequiredServerEnvVar, getTeam} from './misc'
+import {ensurePrimary} from './fly.server'
const DISCORD_CLIENT_ID = getRequiredServerEnvVar('DISCORD_CLIENT_ID')
const DISCORD_CLIENT_SECRET = getRequiredServerEnvVar('DISCORD_CLIENT_SECRET')
@@ -123,7 +124,7 @@ async function updateDiscordRolesForUser(
discordMember: DiscordMember,
user: User,
) {
- await prismaWrite.user.update({
+ await prisma.user.update({
where: {id: user.id},
data: {discordId: discordMember.user.id},
})
@@ -179,6 +180,7 @@ async function connectDiscord({
code: string
domainUrl: string
}) {
+ await ensurePrimary()
const {discordUser, discordToken} = await getUserToken({code, domainUrl})
await addUserToDiscordServer(discordUser, discordToken)
diff --git a/app/utils/env.server.ts b/app/utils/env.server.ts
index 09a0eb7a8..04d751c3c 100644
--- a/app/utils/env.server.ts
+++ b/app/utils/env.server.ts
@@ -3,7 +3,6 @@ function getEnv() {
FLY: process.env.FLY,
NODE_ENV: process.env.NODE_ENV,
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID,
- PRIMARY_REGION: process.env.PRIMARY_REGION,
}
}
diff --git a/app/utils/fly.server.ts b/app/utils/fly.server.ts
new file mode 100644
index 000000000..e8aa990f2
--- /dev/null
+++ b/app/utils/fly.server.ts
@@ -0,0 +1,51 @@
+import fs from 'fs'
+import os from 'os'
+import path from 'path'
+import invariant from 'tiny-invariant'
+
+export async function ensurePrimary() {
+ const {currentIsPrimary, currentInstance, primaryInstance} =
+ await getInstanceInfo()
+
+ if (!currentIsPrimary) {
+ console.log(
+ `Instance (${currentInstance}) in ${process.env.FLY_REGION} is not primary (primary is: ${primaryInstance}), sending fly replay response`,
+ )
+ throw new Response('Fly Replay', {
+ status: 409,
+ headers: {'fly-replay': `instance=${primaryInstance}`},
+ })
+ }
+}
+
+export async function getInstanceInfo() {
+ const currentInstance = os.hostname()
+ let primaryInstance
+ try {
+ const {FLY_LITEFS_DIR} = process.env
+ invariant(FLY_LITEFS_DIR, 'FLY_LITEFS_DIR is not defined')
+ primaryInstance = await fs.promises.readFile(
+ path.join(FLY_LITEFS_DIR, '.primary'),
+ 'utf8',
+ )
+ primaryInstance = primaryInstance.trim()
+ } catch (error: unknown) {
+ primaryInstance = currentInstance
+ }
+ return {
+ primaryInstance,
+ currentInstance,
+ currentIsPrimary: currentInstance === primaryInstance,
+ }
+}
+
+export async function getFlyReplayResponse(instance?: string) {
+ return new Response('Fly Replay', {
+ status: 409,
+ headers: {
+ 'fly-replay': `instance=${
+ instance ?? (await getInstanceInfo()).primaryInstance
+ }`,
+ },
+ })
+}
diff --git a/app/utils/github.server.ts b/app/utils/github.server.ts
index f79f118e9..8cab08635 100644
--- a/app/utils/github.server.ts
+++ b/app/utils/github.server.ts
@@ -3,6 +3,8 @@ import {Octokit as createOctokit} from '@octokit/rest'
import {throttling} from '@octokit/plugin-throttling'
import type {GitHubFile} from '~/types'
+const ref = process.env.GITHUB_REF ?? 'main'
+
const Octokit = createOctokit.plugin(throttling)
type ThrottleOptions = {
@@ -144,6 +146,7 @@ async function downloadFile(path: string) {
owner: 'kentcdodds',
repo: 'kentcdodds.com',
path,
+ ref,
},
)
@@ -169,6 +172,7 @@ async function downloadDirList(path: string) {
owner: 'kentcdodds',
repo: 'kentcdodds.com',
path,
+ ref,
})
const data = resp.data
diff --git a/app/utils/markdown.server.ts b/app/utils/markdown.server.ts
index bae25b4d5..32c3bc7c8 100644
--- a/app/utils/markdown.server.ts
+++ b/app/utils/markdown.server.ts
@@ -32,7 +32,6 @@ async function markdownToHtmlDocument(markdownString: string) {
.use(rehypeStringify)
.process(markdownString)
- console.log(result)
return result.value.toString()
}
@@ -51,20 +50,3 @@ export {
markdownToHtmlDocument,
stripHtml,
}
-
-// async function go() {
-// console.log(
-// await markdownToHtml(
-// `
-// # helo
-
-// this is stuff
-
-//
-// `.trim(),
-// ),
-// )
-// }
-// go()
diff --git a/app/utils/mdx.tsx b/app/utils/mdx.tsx
index 2913d4ee5..2ce8e4b15 100644
--- a/app/utils/mdx.tsx
+++ b/app/utils/mdx.tsx
@@ -3,15 +3,14 @@ import {buildImageUrl} from 'cloudinary-build-url'
import type {LoaderData as RootLoaderData} from '../root'
import type {GitHubFile, MdxListItem, MdxPage} from '~/types'
import * as mdxBundler from 'mdx-bundler/client'
+import {cachified, verboseReporter} from 'cachified'
import {compileMdx} from '~/utils/compile-mdx.server'
import {
downloadDirList,
downloadMdxFileOrDirectory,
} from '~/utils/github.server'
import {AnchorOrLink, getDisplayUrl, getUrl, typedBoolean} from '~/utils/misc'
-import {redisCache} from './redis.server'
-import type {Timings} from './metrics.server'
-import {cachified} from './cache.server'
+import {cache, shouldForceFresh} from './cache.server'
import {getSocialMetas} from './seo'
import {
getImageBuilder,
@@ -25,15 +24,12 @@ import {ConvertKitForm} from '~/convertkit/form'
type CachifiedOptions = {
forceFresh?: boolean | string
request?: Request
- timings?: Timings
- maxAge?: number
- expires?: Date
+ ttl?: number
}
-const defaultMaxAge = 1000 * 60 * 60 * 24 * 30
+const defaultTTL = 1000 * 60 * 60 * 24
+const defaultStaleWhileRevalidate = 1000 * 60 * 60 * 24 * 30
-const getCompiledKey = (contentDir: string, slug: string) =>
- `${contentDir}:${slug}:compiled`
const checkCompiledValue = (value: unknown) =>
typeof value === 'object' &&
(value === null || ('code' in value && 'frontmatter' in value))
@@ -48,11 +44,13 @@ async function getMdxPage(
},
options: CachifiedOptions,
): Promise {
- const key = getCompiledKey(contentDir, slug)
+ const {forceFresh, ttl = defaultTTL, request} = options
+ const key = `mdx-page:${contentDir}:${slug}:compiled`
const page = await cachified({
- cache: redisCache,
- maxAge: defaultMaxAge,
- ...options,
+ cache,
+ ttl,
+ staleWhileRevalidate: defaultStaleWhileRevalidate,
+ forceFresh: await shouldForceFresh({forceFresh, request, key}),
// reusing the same key as compiledMdxCached because we just return that
// exact same value. Cachifying this allows us to skip getting the cached files
key,
@@ -76,7 +74,7 @@ async function getMdxPage(
})
if (!page) {
// if there's no page, let's remove it from the cache
- void redisCache.del(key)
+ void cache.delete(key)
}
return page
}
@@ -108,11 +106,14 @@ async function getMdxPagesInDirectory(
const getDirListKey = (contentDir: string) => `${contentDir}:dir-list`
async function getMdxDirList(contentDir: string, options?: CachifiedOptions) {
+ const {forceFresh, ttl = defaultTTL, request} = options ?? {}
+ const key = getDirListKey(contentDir)
return cachified({
- cache: redisCache,
- maxAge: defaultMaxAge,
- ...options,
- key: getDirListKey(contentDir),
+ cache,
+ ttl,
+ staleWhileRevalidate: defaultStaleWhileRevalidate,
+ forceFresh: await shouldForceFresh({forceFresh, request, key}),
+ key,
checkValue: (value: unknown) => Array.isArray(value),
getFreshValue: async () => {
const fullContentDirPath = `content/${contentDir}`
@@ -129,19 +130,18 @@ async function getMdxDirList(contentDir: string, options?: CachifiedOptions) {
})
}
-const getDownloadKey = (contentDir: string, slug: string) =>
- `${contentDir}:${slug}:downloaded`
-
-async function downloadMdxFilesCached(
+export async function downloadMdxFilesCached(
contentDir: string,
slug: string,
options: CachifiedOptions,
) {
- const key = getDownloadKey(contentDir, slug)
+ const {forceFresh, ttl = defaultTTL, request} = options
+ const key = `${contentDir}:${slug}:downloaded`
const downloaded = await cachified({
- cache: redisCache,
- maxAge: defaultMaxAge,
- ...options,
+ cache,
+ ttl,
+ staleWhileRevalidate: defaultStaleWhileRevalidate,
+ forceFresh: await shouldForceFresh({forceFresh, request, key}),
key,
checkValue: (value: unknown) => {
if (typeof value !== 'object') {
@@ -166,7 +166,7 @@ async function downloadMdxFilesCached(
})
// if there aren't any files, remove it from the cache
if (!downloaded.files.length) {
- void redisCache.del(key)
+ void cache.delete(key)
}
return downloaded
}
@@ -184,11 +184,18 @@ async function compileMdxCached({
files: Array
options: CachifiedOptions
}) {
- const key = getCompiledKey(contentDir, slug)
+ const key = `${contentDir}:${slug}:compiled`
const page = await cachified({
- cache: redisCache,
- maxAge: defaultMaxAge,
+ cache,
+ ttl: defaultTTL,
+ staleWhileRevalidate: defaultStaleWhileRevalidate,
+ reporter: verboseReporter(),
...options,
+ forceFresh: await shouldForceFresh({
+ forceFresh: options.forceFresh,
+ request: options.request,
+ key,
+ }),
key,
checkValue: checkCompiledValue,
getFreshValue: async () => {
@@ -236,7 +243,7 @@ async function compileMdxCached({
})
// if there's no page, remove it from the cache
if (!page) {
- void redisCache.del(key)
+ void cache.delete(key)
}
return page
}
@@ -283,11 +290,14 @@ async function getDataUrlForImage(imageUrl: string) {
}
async function getBlogMdxListItems(options: CachifiedOptions) {
+ const {request, forceFresh, ttl = defaultTTL} = options
+ const key = 'blog:mdx-list-items'
return cachified({
- cache: redisCache,
- maxAge: defaultMaxAge,
- ...options,
- key: 'blog:mdx-list-items',
+ cache,
+ ttl,
+ staleWhileRevalidate: defaultStaleWhileRevalidate,
+ forceFresh: await shouldForceFresh({forceFresh, request, key}),
+ key,
getFreshValue: async () => {
let pages = await getMdxPagesInDirectory('blog', options).then(allPosts =>
allPosts.filter(p => !p.frontmatter.draft),
diff --git a/app/utils/metrics.server.ts b/app/utils/metrics.server.ts
deleted file mode 100644
index 66c182395..000000000
--- a/app/utils/metrics.server.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-type Timings = Record>
-
-async function time({
- name,
- type,
- fn,
- timings,
-}: {
- name: string
- type: string
- fn: () => ReturnType | Promise
- timings?: Timings
-}): Promise {
- if (!timings) return fn()
-
- const start = performance.now()
- const result = await fn()
- type = type.replaceAll(' ', '_')
- let timingType = timings[type]
- if (!timingType) {
- // eslint-disable-next-line no-multi-assign
- timingType = timings[type] = []
- }
-
- timingType.push({name, type, time: performance.now() - start})
- return result
-}
-
-function getServerTimeHeader(timings: Timings) {
- return Object.entries(timings)
- .map(([key, timingInfos]) => {
- const dur = timingInfos
- .reduce((acc, timingInfo) => acc + timingInfo.time, 0)
- .toFixed(1)
- const desc = timingInfos.map(t => t.name).join(' & ')
- return `${key};dur=${dur};desc="${desc}"`
- })
- .join(',')
-}
-
-export {time, getServerTimeHeader}
-export type {Timings}
diff --git a/app/utils/misc.tsx b/app/utils/misc.tsx
index 5813a671a..5f399dbef 100644
--- a/app/utils/misc.tsx
+++ b/app/utils/misc.tsx
@@ -237,6 +237,9 @@ function getDiscordAuthorizeURL(domainUrl: string) {
return url.toString()
}
+/**
+ * @returns domain URL (without a ending slash)
+ */
function getDomainUrl(request: Request) {
const host =
request.headers.get('X-Forwarded-Host') ?? request.headers.get('host')
diff --git a/app/utils/prisma.server.ts b/app/utils/prisma.server.ts
index d74620ec6..66a8cdedd 100644
--- a/app/utils/prisma.server.ts
+++ b/app/utils/prisma.server.ts
@@ -1,21 +1,18 @@
import {PrismaClient} from '@prisma/client'
-import chalk from 'chalk'
import type {Session} from '~/types'
import {encrypt, decrypt} from './encryption.server'
+import {ensurePrimary} from './fly.server'
declare global {
// This prevents us from making multiple connections to the db when the
// require cache is cleared.
// eslint-disable-next-line
- var prismaRead: ReturnType | undefined
- // eslint-disable-next-line
- var prismaWrite: ReturnType | undefined
+ var __prisma: ReturnType | undefined
}
const logThreshold = 50
-const prismaRead = global.prismaRead ?? (global.prismaRead = getClient())
-const prismaWrite = prismaRead
+const prisma = global.__prisma ?? (global.__prisma = getClient())
function getClient(): PrismaClient {
// NOTE: during development if you change anything in this function, remember
@@ -31,6 +28,7 @@ function getClient(): PrismaClient {
})
client.$on('query', async e => {
if (e.duration < logThreshold) return
+ const {default: chalk} = await import('chalk')
const color =
e.duration < 30
@@ -144,7 +142,8 @@ async function validateMagicLink(link: string, sessionMagicLink?: string) {
async function createSession(
sessionData: Omit,
) {
- return prismaWrite.session.create({
+ await ensurePrimary()
+ return prisma.session.create({
data: {
...sessionData,
expirationDate: new Date(Date.now() + sessionExpirationTime),
@@ -153,7 +152,7 @@ async function createSession(
}
async function getUserFromSessionId(sessionId: string) {
- const session = await prismaRead.session.findUnique({
+ const session = await prisma.session.findUnique({
where: {id: sessionId},
include: {user: true},
})
@@ -162,15 +161,17 @@ async function getUserFromSessionId(sessionId: string) {
}
if (Date.now() > session.expirationDate.getTime()) {
- await prismaWrite.session.delete({where: {id: sessionId}})
+ await ensurePrimary()
+ await prisma.session.delete({where: {id: sessionId}})
throw new Error('Session expired. Please request a new magic link.')
}
// if there's less than ~six months left, extend the session
const twoWeeks = 1000 * 60 * 60 * 24 * 30 * 6
if (Date.now() + twoWeeks > session.expirationDate.getTime()) {
+ await ensurePrimary()
const newExpirationDate = new Date(Date.now() + sessionExpirationTime)
- await prismaWrite.session.update({
+ await prisma.session.update({
data: {expirationDate: newExpirationDate},
where: {id: sessionId},
})
@@ -182,10 +183,10 @@ async function getUserFromSessionId(sessionId: string) {
async function getAllUserData(userId: string) {
const {default: pProps} = await import('p-props')
return pProps({
- user: prismaRead.user.findUnique({where: {id: userId}}),
- calls: prismaRead.call.findMany({where: {userId}}),
- postReads: prismaRead.postRead.findMany({where: {userId}}),
- sessions: prismaRead.session.findMany({where: {userId}}),
+ user: prisma.user.findUnique({where: {id: userId}}),
+ calls: prisma.call.findMany({where: {userId}}),
+ postReads: prisma.postRead.findMany({where: {userId}}),
+ sessions: prisma.session.findMany({where: {userId}}),
})
}
@@ -198,7 +199,7 @@ async function addPostRead({
| {userId?: undefined; clientId: string}
)) {
const id = userId ? {userId} : {clientId}
- const readInLastWeek = await prismaRead.postRead.findFirst({
+ const readInLastWeek = await prisma.postRead.findFirst({
select: {id: true},
where: {
...id,
@@ -209,7 +210,8 @@ async function addPostRead({
if (readInLastWeek) {
return null
} else {
- const postRead = await prismaWrite.postRead.create({
+ await ensurePrimary()
+ const postRead = await prisma.postRead.create({
data: {postSlug: slug, ...id},
select: {id: true},
})
@@ -218,8 +220,7 @@ async function addPostRead({
}
export {
- prismaRead,
- prismaWrite,
+ prisma,
getMagicLink,
validateMagicLink,
linkExpirationTime,
diff --git a/app/utils/redis.server.ts b/app/utils/redis.server.ts
deleted file mode 100644
index deab1c29c..000000000
--- a/app/utils/redis.server.ts
+++ /dev/null
@@ -1,121 +0,0 @@
-import redis from 'redis'
-import {getRequiredServerEnvVar} from './misc'
-
-declare global {
- // This prevents us from making multiple connections to the db when the
- // require cache is cleared.
- // eslint-disable-next-line
- var replicaClient: redis.RedisClient | undefined,
- primaryClient: redis.RedisClient | undefined
-}
-
-const REDIS_URL = getRequiredServerEnvVar('REDIS_URL')
-const replica = new URL(REDIS_URL)
-const isLocalHost = replica.hostname === 'localhost'
-const isInternal = replica.hostname.includes('.internal')
-
-const isMultiRegion = !isLocalHost && isInternal
-
-const PRIMARY_REGION = isMultiRegion
- ? getRequiredServerEnvVar('PRIMARY_REGION')
- : null
-const FLY_REGION = isMultiRegion ? getRequiredServerEnvVar('FLY_REGION') : null
-
-if (FLY_REGION) {
- replica.host = `${FLY_REGION}.${replica.host}`
-}
-
-const replicaClient = createClient('replicaClient', {
- url: replica.toString(),
- family: isInternal ? 'IPv6' : 'IPv4',
-})
-
-let primaryClient: redis.RedisClient | null = null
-if (FLY_REGION !== PRIMARY_REGION) {
- const primary = new URL(REDIS_URL)
- if (!isLocalHost) {
- primary.host = `${PRIMARY_REGION}.${primary.host}`
- }
- primaryClient = createClient('primaryClient', {
- url: primary.toString(),
- family: isInternal ? 'IPv6' : 'IPv4',
- })
-}
-
-function createClient(
- name: 'replicaClient' | 'primaryClient',
- options: redis.ClientOpts,
-): redis.RedisClient {
- let client = global[name]
- if (!client) {
- const url = new URL(options.url ?? 'http://no-redis-url.example.com?weird')
- console.log(`Setting up redis client to ${url.host} for ${name}`)
- // eslint-disable-next-line no-multi-assign
- client = global[name] = redis.createClient(options)
-
- client.on('error', (error: string) => {
- console.error(`REDIS ${name} (${url.host}) ERROR:`, error)
- })
- }
- return client
-}
-
-// NOTE: Caching should never crash the app, so instead of rejecting all these
-// promises, we'll just resolve things with null and log the error.
-
-function get(key: string): Promise {
- return new Promise(resolve => {
- replicaClient.get(key, (err: Error | null, result: string | null) => {
- if (err) {
- console.error(
- `REDIS replicaClient (${FLY_REGION}) ERROR with .get:`,
- err,
- )
- }
- resolve(result ? (JSON.parse(result) as Value) : null)
- })
- })
-}
-
-function set(key: string, value: Value): Promise<'OK'> {
- return new Promise(resolve => {
- replicaClient.set(
- key,
- JSON.stringify(value),
- (err: Error | null, reply: 'OK') => {
- if (err) {
- console.error(
- `REDIS replicaClient (${FLY_REGION}) ERROR with .set:`,
- err,
- )
- }
- resolve(reply)
- },
- )
- })
-}
-
-function del(key: string): Promise {
- return new Promise(resolve => {
- // fire and forget on primary, we only care about replica
- primaryClient?.del(key, (err: Error | null) => {
- if (err) {
- console.error('Primary delete error', err)
- }
- })
- replicaClient.del(key, (err: Error | null, result: number | null) => {
- if (err) {
- console.error(
- `REDIS replicaClient (${FLY_REGION}) ERROR with .del:`,
- err,
- )
- resolve('error')
- } else {
- resolve(`${key} deleted: ${result}`)
- }
- })
- })
-}
-
-const redisCache = {get, set, del, name: 'redis'}
-export {get, set, del, redisCache}
diff --git a/app/utils/session.server.ts b/app/utils/session.server.ts
index 0f0598ca2..05a83621e 100644
--- a/app/utils/session.server.ts
+++ b/app/utils/session.server.ts
@@ -2,16 +2,16 @@ import {createCookieSessionStorage, redirect} from '@remix-run/node'
import type {User} from '@prisma/client'
import {sendMagicLinkEmail} from './send-email.server'
import {
- prismaRead,
+ prisma,
getMagicLink,
getUserFromSessionId,
- prismaWrite,
validateMagicLink,
createSession,
sessionExpirationTime,
} from './prisma.server'
import {getRequiredServerEnvVar} from './misc'
import {getLoginInfoSession} from './login.server'
+import {ensurePrimary} from './fly.server'
const sessionIdKey = '__session_id__'
@@ -40,7 +40,7 @@ async function sendToken({
domainUrl,
})
- const user = await prismaRead.user
+ const user = await prisma.user
.findUnique({where: {email: emailAddress}})
.catch(() => {
/* ignore... */
@@ -84,11 +84,12 @@ async function getSession(request: Request) {
const userSession = await createSession({userId: user.id})
session.set(sessionIdKey, userSession.id)
},
- signOut: () => {
+ signOut: async () => {
const sessionId = getSessionId()
if (sessionId) {
+ await ensurePrimary()
unsetSessionId()
- prismaWrite.session
+ prisma.session
.delete({where: {id: sessionId}})
.catch((error: unknown) => {
console.error(`Failure deleting user session: `, error)
@@ -127,7 +128,8 @@ async function deleteOtherSessions(request: Request) {
return
}
const user = await getUserFromSessionId(token)
- await prismaWrite.session.deleteMany({
+ await ensurePrimary()
+ await prisma.session.deleteMany({
where: {userId: user.id, NOT: {id: token}},
})
}
@@ -151,7 +153,7 @@ async function getUserSessionFromMagicLink(request: Request) {
loginInfoSession.getMagicLink(),
)
- const user = await prismaRead.user.findUnique({where: {email}})
+ const user = await prisma.user.findUnique({where: {email}})
if (!user) return null
const session = await getSession(request)
@@ -163,7 +165,7 @@ async function requireAdminUser(request: Request): Promise {
const user = await getUser(request)
if (!user) {
const session = await getSession(request)
- session.signOut()
+ await session.signOut()
throw redirect('/login', {headers: await session.getHeaders()})
}
if (user.role !== 'ADMIN') {
@@ -176,7 +178,7 @@ async function requireUser(request: Request): Promise {
const user = await getUser(request)
if (!user) {
const session = await getSession(request)
- session.signOut()
+ await session.signOut()
throw redirect('/login', {headers: await session.getHeaders()})
}
return user
diff --git a/app/utils/simplecast.server.ts b/app/utils/simplecast.server.ts
index 9935bc5a7..b509799f7 100644
--- a/app/utils/simplecast.server.ts
+++ b/app/utils/simplecast.server.ts
@@ -13,8 +13,8 @@ import type * as M from 'mdast'
import type * as H from 'hast'
import {getRequiredServerEnvVar, typedBoolean} from './misc'
import {markdownToHtml, stripHtml} from './markdown.server'
-import {redisCache} from './redis.server'
-import {cachified} from './cache.server'
+import {cache, shouldForceFresh} from './cache.server'
+import {cachified} from 'cachified'
const SIMPLECAST_KEY = getRequiredServerEnvVar('SIMPLECAST_KEY')
const CHATS_WITH_KENT_PODCAST_ID = getRequiredServerEnvVar(
@@ -43,12 +43,16 @@ const getCachedSeasons = async ({
forceFresh?: boolean
}) =>
cachified({
- cache: redisCache,
+ cache,
key: seasonsCacheKey,
- maxAge: 1000 * 60 * 60 * 24 * 7,
+ ttl: 1000 * 60 * 60 * 24 * 7,
+ staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30,
getFreshValue: () => getSeasons({request, forceFresh}),
- request,
- forceFresh,
+ forceFresh: await shouldForceFresh({
+ forceFresh,
+ request,
+ key: seasonsCacheKey,
+ }),
checkValue: (value: unknown) =>
Array.isArray(value) &&
value.length > 0 &&
@@ -57,7 +61,7 @@ const getCachedSeasons = async ({
),
})
-const getCachedEpisode = async (
+async function getCachedEpisode(
episodeId: string,
{
request,
@@ -66,17 +70,19 @@ const getCachedEpisode = async (
request: Request
forceFresh?: boolean
},
-) =>
- cachified({
- cache: redisCache,
- key: `simplecast:episode:${episodeId}`,
- maxAge: 1000 * 60 * 60 * 24 * 7,
+) {
+ const key = `simplecast:episode:${episodeId}`
+ return cachified({
+ cache,
+ key,
+ ttl: 1000 * 60 * 60 * 24 * 7,
+ staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30,
getFreshValue: () => getEpisode(episodeId),
- request,
- forceFresh,
+ forceFresh: await shouldForceFresh({forceFresh, request, key}),
checkValue: (value: unknown) =>
typeof value === 'object' && value !== null && 'title' in value,
})
+}
async function getSeasons({
request,
diff --git a/app/utils/talks.server.ts b/app/utils/talks.server.ts
index 784e9bac3..ebc87abd7 100644
--- a/app/utils/talks.server.ts
+++ b/app/utils/talks.server.ts
@@ -4,8 +4,8 @@ import type {Await} from '~/types'
import {typedBoolean} from '~/utils/misc'
import {markdownToHtml, stripHtml} from '~/utils/markdown.server'
import {downloadFile} from '~/utils/github.server'
-import {cachified} from '~/utils/cache.server'
-import {redisCache} from '~/utils/redis.server'
+import {cachified} from 'cachified'
+import {cache, shouldForceFresh} from '~/utils/cache.server'
type RawTalk = {
title?: string
@@ -114,12 +114,13 @@ async function getTalksAndTags({
const slugify = await getSlugify()
slugify.reset()
+ const key = 'content:data:talks.yml'
const talks = await cachified({
- cache: redisCache,
- key: 'content:data:talks.yml',
- maxAge: 1000 * 60 * 60 * 24 * 14,
- request,
- forceFresh,
+ cache,
+ key,
+ ttl: 1000 * 60 * 60 * 24 * 14,
+ staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30,
+ forceFresh: await shouldForceFresh({forceFresh, request, key}),
getFreshValue: async () => {
const talksString = await downloadFile('content/data/talks.yml')
const rawTalks = YAML.parse(talksString) as Array
diff --git a/app/utils/testimonials.server.ts b/app/utils/testimonials.server.ts
index 2a32d9124..cc32f4ec2 100644
--- a/app/utils/testimonials.server.ts
+++ b/app/utils/testimonials.server.ts
@@ -1,9 +1,9 @@
import * as YAML from 'yaml'
import {pick} from 'lodash'
+import {cachified} from 'cachified'
import {downloadFile} from './github.server'
import {getErrorMessage, typedBoolean} from './misc'
-import {redisCache} from './redis.server'
-import {cachified} from './cache.server'
+import {cache, shouldForceFresh} from './cache.server'
const allCategories = [
'teaching',
@@ -150,12 +150,13 @@ async function getAllTestimonials({
request?: Request
forceFresh?: boolean
}) {
+ const key = 'content:data:testimonials.yml'
const allTestimonials = await cachified({
- cache: redisCache,
- key: 'content:data:testimonials.yml',
- request,
- forceFresh,
- maxAge: 1000 * 60 * 60 * 24,
+ cache,
+ key,
+ forceFresh: await shouldForceFresh({forceFresh, request, key}),
+ ttl: 1000 * 60 * 60 * 24,
+ staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30,
getFreshValue: async (): Promise> => {
const talksString = await downloadFile('content/data/testimonials.yml')
const rawTestimonials = YAML.parse(talksString)
diff --git a/app/utils/theme-provider.tsx b/app/utils/theme-provider.tsx
index 76aa0f5b3..0fc184ad7 100644
--- a/app/utils/theme-provider.tsx
+++ b/app/utils/theme-provider.tsx
@@ -28,7 +28,7 @@ function ThemeProvider({
children: React.ReactNode
specifiedTheme: Theme | null
}) {
- const [theme, setTheme] = React.useState(() => {
+ const [theme, setThemeState] = React.useState(() => {
// On the server, if we don't have a specified theme then we should
// return null and the clientThemeCode will set the theme for us
// before hydration. Then (during hydration), this code will get the same
@@ -52,30 +52,29 @@ function ThemeProvider({
persistThemeRef.current = persistTheme
}, [persistTheme])
- const mountRun = React.useRef(false)
-
- React.useEffect(() => {
- if (!mountRun.current) {
- mountRun.current = true
- return
- }
- if (!theme) return
-
- persistThemeRef.current.submit(
- {theme},
- {action: 'action/set-theme', method: 'post'},
- )
- }, [theme])
-
React.useEffect(() => {
const mediaQuery = window.matchMedia(prefersLightMQ)
const handleChange = () => {
- setTheme(mediaQuery.matches ? Theme.LIGHT : Theme.DARK)
+ setThemeState(mediaQuery.matches ? Theme.LIGHT : Theme.DARK)
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
+ const setTheme = React.useCallback(
+ (cb: Parameters[0]) => {
+ const newTheme = typeof cb === 'function' ? cb(theme) : cb
+ if (newTheme) {
+ persistThemeRef.current.submit(
+ {theme: newTheme},
+ {action: 'action/set-theme', method: 'post'},
+ )
+ }
+ setThemeState(newTheme)
+ },
+ [theme],
+ )
+
return (
{children}
diff --git a/app/utils/transistor.server.ts b/app/utils/transistor.server.ts
index 4c8b88829..b3404cea4 100644
--- a/app/utils/transistor.server.ts
+++ b/app/utils/transistor.server.ts
@@ -1,4 +1,5 @@
import * as uuid from 'uuid'
+import {cachified} from 'cachified'
import type {
TransistorErrorResponse,
TransistorCreateEpisodeData,
@@ -10,12 +11,10 @@ import type {
TransistorUpdateEpisodeData,
} from '~/types'
import {getDomainUrl, getRequiredServerEnvVar, toBase64} from './misc'
-import {redisCache} from './redis.server'
-import {cachified} from './cache.server'
+import {cache, shouldForceFresh} from './cache.server'
import {getEpisodePath} from './call-kent'
import {getDirectAvatarForUser} from './user-info.server'
import {stripHtml} from './markdown.server'
-import type {Team} from '@prisma/client'
const transistorApiSecret = getRequiredServerEnvVar('TRANSISTOR_API_SECRET')
const podcastId = getRequiredServerEnvVar('CALL_KENT_PODCAST_ID', '67890')
@@ -73,7 +72,7 @@ async function createEpisode({
summary: string
description: string
keywords: string
- user: {firstName: string; email: string; team: Team}
+ user: {firstName: string; email: string; team: string}
request: Request
avatar?: string | null
}) {
@@ -253,12 +252,16 @@ async function getCachedEpisodes({
forceFresh?: boolean
}) {
return cachified({
- cache: redisCache,
+ cache,
key: episodesCacheKey,
getFreshValue: getEpisodes,
- maxAge: 1000 * 60 * 60 * 24,
- forceFresh,
- request,
+ ttl: 1000 * 60 * 60 * 24,
+ staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30,
+ forceFresh: await shouldForceFresh({
+ forceFresh,
+ request,
+ key: episodesCacheKey,
+ }),
checkValue: (value: unknown) =>
Array.isArray(value) &&
value.every(
diff --git a/app/utils/twitter.server.ts b/app/utils/twitter.server.ts
index 9fecce86a..08c5b68c4 100644
--- a/app/utils/twitter.server.ts
+++ b/app/utils/twitter.server.ts
@@ -10,6 +10,8 @@ import {
getRequiredServerEnvVar,
typedBoolean,
} from './misc'
+import cachified from 'cachified'
+import {cache, lruCache} from './cache.server'
const token = getRequiredServerEnvVar('TWITTER_BEARER_TOKEN')
@@ -130,8 +132,23 @@ type TweetErrorJsonResponse = {
}>
}
-// fetch tweet from API
+type TweetRateLimitErrorJsonResponse = {
+ title: 'Too Many Requests'
+ detail: 'Too Many Requests'
+ type: 'about:blank'
+ status: 429
+}
+
async function getTweet(tweetId: string) {
+ return cachified({
+ key: `tweet:${tweetId}`,
+ cache: lruCache,
+ ttl: 1000 * 60,
+ getFreshValue: () => getTweetImpl(tweetId),
+ })
+}
+
+async function getTweetImpl(tweetId: string) {
const url = new URL(`https://api.twitter.com/2/tweets/${tweetId}`)
const params = {
'tweet.fields': 'public_metrics,created_at',
@@ -150,7 +167,10 @@ async function getTweet(tweetId: string) {
},
})
const tweetJson = await response.json()
- return tweetJson as TweetJsonResponse | TweetErrorJsonResponse
+ return tweetJson as
+ | TweetJsonResponse
+ | TweetErrorJsonResponse
+ | TweetRateLimitErrorJsonResponse
}
const playSvg = ``
@@ -358,6 +378,16 @@ async function buildTweetHTML(
}
async function getTweetEmbedHTML(urlString: string) {
+ return cachified({
+ key: `tweet:embed:${urlString}`,
+ ttl: 1000 * 60 * 60 * 24,
+ cache,
+ staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30 * 6,
+ getFreshValue: () => getTweetEmbedHTMLImpl(urlString),
+ })
+}
+
+async function getTweetEmbedHTMLImpl(urlString: string) {
const url = new URL(urlString)
const tweetId = url.pathname.split('/').pop()
@@ -368,6 +398,10 @@ async function getTweetEmbedHTML(urlString: string) {
let tweet
try {
tweet = await getTweet(tweetId)
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if ('status' in tweet && tweet.status === 429) {
+ throw new Error(`Rate limited: ${tweetId}`)
+ }
if (!('data' in tweet)) {
throw new Error('Oh no, tweet has no data.')
}
@@ -381,7 +415,7 @@ async function getTweetEmbedHTML(urlString: string) {
console.error(er)
}
}
- return ''
+ throw error
}
}
diff --git a/app/utils/user-info.server.ts b/app/utils/user-info.server.ts
index bc9d466bb..9d5e3b3c4 100644
--- a/app/utils/user-info.server.ts
+++ b/app/utils/user-info.server.ts
@@ -2,10 +2,9 @@ import type {User} from '~/types'
import {getImageBuilder, images} from '../images'
import * as ck from '../convertkit/convertkit.server'
import * as discord from './discord.server'
-import type {Timings} from './metrics.server'
import {getAvatar, getDomainUrl, getOptionalTeam} from './misc'
-import {redisCache} from './redis.server'
-import {cachified} from './cache.server'
+import {cache, shouldForceFresh} from './cache.server'
+import cachified from 'cachified'
type UserInfo = {
avatar: {
@@ -56,20 +55,20 @@ const getDiscordCacheKey = (discordId: string) => `discord:${discordId}`
async function getUserInfo(
user: User,
- {
- request,
- forceFresh,
- timings,
- }: {request: Request; forceFresh?: boolean; timings?: Timings},
+ {request, forceFresh}: {request: Request; forceFresh?: boolean},
) {
const {discordId, convertKitId, email} = user
const [discordUser, convertKitInfo] = await Promise.all([
discordId
? cachified({
- cache: redisCache,
- request,
- forceFresh,
- maxAge: 1000 * 60 * 60 * 24 * 30,
+ cache,
+ forceFresh: await shouldForceFresh({
+ forceFresh,
+ request,
+ key: getDiscordCacheKey(discordId),
+ }),
+ ttl: 1000 * 60 * 60 * 24 * 30,
+ staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30,
key: getDiscordCacheKey(discordId),
checkValue: (value: unknown) =>
typeof value === 'object' && value !== null && 'id' in value,
@@ -81,11 +80,14 @@ async function getUserInfo(
: null,
convertKitId
? cachified({
- cache: redisCache,
- request,
- forceFresh,
- maxAge: 1000 * 60 * 60 * 24 * 30,
- timings,
+ cache,
+ forceFresh: await shouldForceFresh({
+ forceFresh,
+ request,
+ key: getConvertKitCacheKey(convertKitId),
+ }),
+ ttl: 1000 * 60 * 60 * 24 * 30,
+ staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30,
key: getConvertKitCacheKey(convertKitId),
checkValue: (value: unknown) =>
typeof value === 'object' && value !== null && 'tags' in value,
@@ -121,12 +123,12 @@ async function getUserInfo(
return userInfo
}
-function deleteConvertKitCache(convertKitId: string | number) {
- return redisCache.del(getConvertKitCacheKey(String(convertKitId)))
+async function deleteConvertKitCache(convertKitId: string | number) {
+ await cache.delete(getConvertKitCacheKey(String(convertKitId)))
}
-function deleteDiscordCache(discordId: string) {
- return redisCache.del(getDiscordCacheKey(discordId))
+async function deleteDiscordCache(discordId: string) {
+ await cache.delete(getDiscordCacheKey(discordId))
}
export {
diff --git a/app/utils/workshop-tickets.server.ts b/app/utils/workshop-tickets.server.ts
index e599ee94c..7653d1218 100644
--- a/app/utils/workshop-tickets.server.ts
+++ b/app/utils/workshop-tickets.server.ts
@@ -1,5 +1,5 @@
-import {cachified} from './cache.server'
-import {redisCache} from './redis.server'
+import {cachified} from 'cachified'
+import {cache, shouldForceFresh} from './cache.server'
type TiToDiscount = {
code: string
@@ -156,14 +156,15 @@ async function getCachedScheduledEvents({
request: Request
forceFresh?: boolean
}) {
+ const key = 'tito:scheduled-events'
const scheduledEvents = await cachified({
- key: 'tito:scheduled-events',
- cache: redisCache,
+ key,
+ cache,
getFreshValue: getScheduledEvents,
checkValue: (value: unknown) => Array.isArray(value),
- request,
- forceFresh,
- maxAge: 1000 * 60 * 24,
+ forceFresh: await shouldForceFresh({forceFresh, request, key}),
+ ttl: 1000 * 60 * 24,
+ staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30,
})
return scheduledEvents
}
diff --git a/app/utils/workshops.server.ts b/app/utils/workshops.server.ts
index 6a58127bd..65dbb7a7c 100644
--- a/app/utils/workshops.server.ts
+++ b/app/utils/workshops.server.ts
@@ -1,11 +1,10 @@
import * as YAML from 'yaml'
import {markdownToHtmlUnwrapped} from './markdown.server'
-import type {Timings} from './metrics.server'
-import {redisCache} from './redis.server'
-import {cachified} from './cache.server'
+import {cachified} from 'cachified'
import {downloadDirList, downloadFile} from './github.server'
import {typedBoolean} from './misc'
import type {Workshop} from '~/types'
+import {cache, shouldForceFresh} from './cache.server'
type RawWorkshop = {
title?: string
@@ -20,22 +19,20 @@ type RawWorkshop = {
prerequisite?: string
}
-function getWorkshops({
+async function getWorkshops({
request,
forceFresh,
- timings,
}: {
request?: Request
forceFresh?: boolean
- timings?: Timings
}) {
+ const key = 'content:workshops'
return cachified({
- cache: redisCache,
- key: 'content:workshops',
- maxAge: 1000 * 60 * 60 * 24 * 7,
- request,
- forceFresh,
- timings,
+ cache,
+ key,
+ ttl: 1000 * 60 * 60 * 24 * 7,
+ staleWhileRevalidate: 1000 * 60 * 60 * 24 * 30,
+ forceFresh: await shouldForceFresh({forceFresh, request, key}),
getFreshValue: async () => {
const dirList = await downloadDirList(`content/workshops`)
const workshopFileList = dirList
diff --git a/content/blog/aha-testing/index.mdx b/content/blog/aha-testing/index.mdx
index 789d3c4d5..f77cf8b5d 100644
--- a/content/blog/aha-testing/index.mdx
+++ b/content/blog/aha-testing/index.mdx
@@ -15,8 +15,6 @@ meta:
translations:
- language: ็ฎไฝไธญๆ
link: https://juejin.cn/post/7086704811927666719/
- - language: ๆฅๆฌ่ช
- link: https://makotot.dev/posts/aha-testing-translation-ja
bannerCloudinaryId: unsplash/photo-1522424427542-e6fc86ff5253
bannerCredit:
Photo by [Alexandru Goman](https://unsplash.com/photos/CM-qccHaQ04)
diff --git a/content/blog/answers-to-common-questions-about-render-props.mdx b/content/blog/answers-to-common-questions-about-render-props.mdx
index a90a11d65..54ec169c4 100644
--- a/content/blog/answers-to-common-questions-about-render-props.mdx
+++ b/content/blog/answers-to-common-questions-about-render-props.mdx
@@ -122,10 +122,6 @@ Another fairly common question is how to get access to the render prop arguments
in lifecycle hooks (because your render prop function is called within the
context of the `render` of your component, how do you get it into
`componentDidMount`.
-[This](https://twitter.com/SavePointSam/status/954515218616340480) was asked by
-[@SavePointSam](https://twitter.com/SavePointSam):
-
-https://twitter.com/SavePointSam/status/954515218616340480
The answer to this is actually sort of hidden in the answer to Mark's question
above. Notice that thanks to React's composability, we can create a separate
diff --git a/content/blog/how-i-built-a-modern-website-in-2021.mdx b/content/blog/how-i-built-a-modern-website-in-2021.mdx
index 65ffd63d6..5b77367a0 100644
--- a/content/blog/how-i-built-a-modern-website-in-2021.mdx
+++ b/content/blog/how-i-built-a-modern-website-in-2021.mdx
@@ -843,7 +843,7 @@ And oh, what if I wanted to also get all the posts this user has read? Do I need
some graphql resolver magic? Nope! Check this out:
```ts
-const users = await prismaRead.user.findMany({
+const users = await prisma.user.findMany({
select: {
id: true,
email: true,
@@ -876,8 +876,8 @@ Now _that's_ what I'm talking about! And with Remix, I can easily query directly
in my `loader`, and then have that **typed** data available in my component:
```tsx
-export async function loader({request}: LoaderArgs) {
- const users = await prismaRead.user.findMany({
+export async function loader({request}: DataFunctionArgs) {
+ const users = await prisma.user.findMany({
select: {
id: true,
email: true,
diff --git a/cypress.config.ts b/cypress.config.ts
deleted file mode 100644
index 9cfe730fc..000000000
--- a/cypress.config.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import {defineConfig} from 'cypress'
-
-export default defineConfig({
- e2e: {
- setupNodeEvents: (on, config) => {
- const isDev = config.watchForFileChanges
- const port = process.env.PORT ?? (isDev ? '3000' : '8811')
- const configOverrides: Partial = {
- projectId: '4rxk45',
- baseUrl: `http://localhost:${port}`,
- viewportWidth: 1030,
- viewportHeight: 800,
- video: !process.env.CI,
- screenshotOnRunFailure: !process.env.CI,
- }
-
- // To use this:
- // cy.task('log', whateverYouWantInTheTerminal)
- on('task', {
- log: message => {
- console.log(message)
-
- return null
- },
- })
-
- on('before:browser:launch', (browser, options) => {
- if (browser.name === 'chrome') {
- options.args.push(
- '--no-sandbox',
- '--allow-file-access-from-files',
- '--use-fake-ui-for-media-stream',
- '--use-fake-device-for-media-stream',
- '--use-file-for-fake-audio-capture=cypress/fixtures/sample.wav',
- )
- }
- return options
- })
-
- return {...config, ...configOverrides}
- },
- },
-})
diff --git a/cypress/e2e/calls.cy.ts b/cypress/e2e/calls.cy.ts
deleted file mode 100644
index cc59937cf..000000000
--- a/cypress/e2e/calls.cy.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import {faker} from '@faker-js/faker'
-
-describe('call in', () => {
- it('should allow creating a call, response, and podcast', () => {
- const title = faker.lorem.words(2)
-
- cy.login()
- cy.visit('/calls')
- cy.findAllByRole('link', {name: /record/i})
- .first()
- .click()
- cy.findByRole('link', {name: /new recording/i}).click()
- cy.findByRole('main').within(() => {
- cy.findByRole('button', {name: /current.*device/i}).click()
- // this is hidden by the label, but it's definitely clickable
- cy.findByRole('checkbox', {name: /default/i}).click({force: true})
- cy.findByRole('button', {name: /start/i}).click()
- cy.wait(50)
- cy.findByRole('button', {name: /pause/i}).click()
- cy.findByRole('button', {name: /resume/i}).click()
- cy.wait(50)
- cy.findByRole('button', {name: /stop/i}).click()
- cy.findByRole('button', {name: /re-record/i}).click()
-
- cy.findByRole('button', {name: /start/i}).click()
- cy.wait(500)
- cy.findByRole('button', {name: /stop/i}).click()
-
- cy.findByRole('button', {name: /accept/i}).click()
- cy.findByRole('textbox', {name: /title/i}).type(title)
- cy.findByRole('textbox', {name: /description/i}).type(
- faker.lorem.paragraph(),
- {delay: 0},
- )
- cy.findByRole('textbox', {name: /keywords/i}).type(
- faker.lorem.words(3).split(' ').join(','),
- {delay: 0},
- )
- cy.findByRole('button', {name: /submit/i}).click()
- })
-
- // login as admin
- cy.login({role: 'ADMIN'})
- cy.visit('/calls/admin')
- cy.findByRole('main').within(() => {
- cy.findByRole('link', {name: new RegExp(title, 'i')}).click()
-
- cy.findByRole('button', {name: /start/i}).click()
- cy.wait(500)
- cy.findByRole('button', {name: /stop/i}).click()
-
- cy.findByRole('button', {name: /accept/i}).click()
- // processing the audio takes a while, so let the timeout run
- cy.findByRole('button', {name: /submit/i}).click({timeout: 10000})
- })
- })
-})
diff --git a/cypress/e2e/contact.cy.ts b/cypress/e2e/contact.cy.ts
deleted file mode 100644
index ed686783a..000000000
--- a/cypress/e2e/contact.cy.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import {faker} from '@faker-js/faker'
-
-describe('contact', () => {
- it('should allow a typical user flow', () => {
- const firstName = faker.name.firstName()
- const emailData = {
- email: faker.internet.email(
- firstName,
- faker.name.lastName(),
- 'example.com',
- ),
- firstName,
- subject: `CONTACT: ${faker.lorem.words(3)}`,
- body: faker.lorem.paragraphs(1).slice(0, 60),
- }
- const bodyPart1 = emailData.body.slice(0, 30)
- const bodyPart2 = emailData.body.slice(30)
- cy.login({email: emailData.email, firstName: emailData.firstName})
- cy.visit('/contact')
-
- cy.findByRole('main').within(() => {
- cy.findByRole('textbox', {name: /name/i}).should(
- 'have.value',
- emailData.firstName,
- )
- cy.findByRole('textbox', {name: /email/i}).should(
- 'have.value',
- emailData.email,
- )
- cy.findByRole('textbox', {name: /subject/i}).type(emailData.subject)
- cy.findByRole('textbox', {name: /body/i}).type(bodyPart1)
-
- cy.findByRole('button', {name: /send/i}).click()
-
- cy.findByRole('alert').should('contain', /too short/i)
-
- cy.findByRole('textbox', {name: /body/i}).type(bodyPart2)
-
- cy.findByRole('button', {name: /send/i}).click()
- })
-
- cy.wait(200)
- cy.readFile('mocks/msw.local.json').then(
- (data: {email: {html: string}}) => {
- expect(data.email).to.include({
- from: `"${emailData.firstName}" <${emailData.email}>`,
- subject: emailData.subject,
- text: emailData.body,
- })
- expect(data.email.html).to.match(/ {
- it('should allow a user to connect their discord account', () => {
- cy.login()
-
- cy.visit('/me')
-
- cy.findByRole('main').within(() => {
- cy.findByRole('link', {name: /connect/i}).then(link => {
- const href = link.attr('href') as string
- const redirectURI = new URL(href).searchParams.get('redirect_uri')
- if (!redirectURI) {
- throw new Error(
- 'The connect link does not have a redirect_uri parameter.',
- )
- }
-
- const nextLocation = new URL(redirectURI)
- nextLocation.searchParams.set('code', 'test_discord_auth_code')
- cy.visit(nextLocation.toString())
- })
- })
-
- cy.findByRole('main').within(() => {
- // eventually this should probably be improved but it's ok for now.
- // using hard coded IDs like this is not awesome.
- cy.findByDisplayValue(/test_discord_username/i)
- })
- })
-})
diff --git a/cypress/e2e/onboarding.cy.ts b/cypress/e2e/onboarding.cy.ts
deleted file mode 100644
index f86334603..000000000
--- a/cypress/e2e/onboarding.cy.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import {faker} from '@faker-js/faker'
-
-describe('onboarding', () => {
- it('should allow a user to register a new account', () => {
- const firstName = faker.name.firstName()
- const email = faker.internet.email(
- firstName,
- faker.name.lastName(),
- 'example.com',
- )
- cy.visit('/')
-
- cy.findByRole('navigation').within(() => {
- cy.findByRole('link', {name: /login/i}).click()
- })
-
- cy.findByRole('main').within(() => {
- cy.findByRole('textbox', {name: /email/i}).type(`${email}{enter}`)
- cy.wait(200)
- cy.readFile('mocks/msw.local.json').then(
- (data: {email: {text: string}}) => {
- const magicLink = data.email.text.match(/(http.+magic.+)\n/)?.[1]
- if (magicLink) {
- return cy.visit(magicLink)
- }
- throw new Error('Could not find magic link email')
- },
- )
- })
-
- cy.findByRole('main').within(() => {
- cy.findByRole('textbox', {name: /name/i}).type(firstName)
- cy.findByRole('group', {name: /team/i}).within(() => {
- // checkbox is covered with a