♻️ Migrate from prisma to drizzle (#1434)

* ♻️ Migrate from prisma to drizzle
* 🐛 Build issue with CalendarTile
* 🚧 Temporary solution for docker container
* 🐛 Drizzle not using DATABASE_URL
* ♻️ Address pull request feedback
* 🐛 Remove console log of env variables
* 🐛 Some unit tests not working
* 🐋 Revert docker tool changes
* 🐛 Issue with board slug page for logged in users

---------

Co-authored-by: Thomas Camlong <thomascamlong@gmail.com>
This commit is contained in:
Meier Lukas
2023-10-08 12:10:48 +02:00
committed by GitHub
parent 4945725702
commit 1d50e2ce9a
34 changed files with 3274 additions and 1507 deletions

View File

@@ -1,6 +1,7 @@
import { TRPCError } from '@trpc/server';
import fs from 'fs';
import { z } from 'zod';
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
import { configExists } from '~/tools/config/configExists';
import { getConfig } from '~/tools/config/getConfig';
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
@@ -13,11 +14,7 @@ export const boardRouter = createTRPCRouter({
all: protectedProcedure.query(async ({ ctx }) => {
const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json'));
const userSettings = await ctx.prisma.userSettings.findUniqueOrThrow({
where: {
userId: ctx.session?.user.id,
},
});
const defaultBoard = await getDefaultBoardAsync(ctx.session.user.id, 'default');
return await Promise.all(
files.map(async (file) => {
@@ -31,7 +28,7 @@ export const boardRouter = createTRPCRouter({
countApps: countApps,
countWidgets: config.widgets.length,
countCategories: config.categories.length,
isDefaultForUser: name === userSettings.defaultBoard,
isDefaultForUser: name === defaultBoard,
};
})
);

View File

@@ -1,6 +1,9 @@
import { randomBytes } from 'crypto';
import { randomBytes, randomUUID } from 'crypto';
import dayjs from 'dayjs';
import { eq, sql } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '~/server/db';
import { invites } from '~/server/db/schema';
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
@@ -14,22 +17,25 @@ export const inviteRouter = createTRPCRouter({
)
.query(async ({ ctx, input }) => {
const limit = input.limit ?? 50;
const invites = await ctx.prisma.invite.findMany({
take: limit,
skip: limit * input.page,
include: {
const dbInvites = await db.query.invites.findMany({
limit: limit,
offset: limit * input.page,
with: {
createdBy: {
select: {
columns: {
name: true,
},
},
},
});
const inviteCount = await ctx.prisma.invite.count();
const inviteCount = await db
.select({ count: sql<number>`count(*)` })
.from(invites)
.then((rows) => rows[0].count);
return {
invites: invites.map((token) => ({
invites: dbInvites.map((token) => ({
id: token.id,
expires: token.expires,
creator: token.createdBy.name,
@@ -47,27 +53,21 @@ export const inviteRouter = createTRPCRouter({
})
)
.mutation(async ({ ctx, input }) => {
const token = await ctx.prisma.invite.create({
data: {
expires: input.expiration,
createdById: ctx.session.user.id,
token: randomBytes(20).toString('hex'),
},
});
const inviteToInsert = {
id: randomUUID(),
expires: input.expiration,
createdById: ctx.session.user.id,
token: randomBytes(20).toString('hex'),
};
await db.insert(invites).values(inviteToInsert);
return {
id: token.id,
token: token.token,
expires: token.expires,
id: inviteToInsert.id,
token: inviteToInsert.token,
expires: inviteToInsert.expires,
};
}),
delete: adminProcedure
.input(z.object({ tokenId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.invite.delete({
where: {
id: input.tokenId,
},
});
}),
delete: adminProcedure.input(z.object({ tokenId: z.string() })).mutation(async ({ input }) => {
await db.delete(invites).where(eq(invites.id, input.tokenId));
}),
});

View File

@@ -1,7 +1,11 @@
import { UserSettings } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import bcrypt from 'bcryptjs';
import { randomUUID } from 'crypto';
import { eq, like, sql } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '~/server/db';
import { getTotalUserCountAsync } from '~/server/db/queries/user';
import { UserSettings, invites, userSettings, users } from '~/server/db/schema';
import { hashPassword } from '~/utils/security';
import {
colorSchemeParser,
@@ -11,24 +15,18 @@ import {
} from '~/validations/user';
import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../../../data/constants';
import {
TRPCContext,
adminProcedure,
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from '../trpc';
import { adminProcedure, createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
export const userRouter = createTRPCRouter({
createOwnerAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => {
const userCount = await ctx.prisma.user.count();
const userCount = await getTotalUserCountAsync();
if (userCount > 0) {
throw new TRPCError({
code: 'FORBIDDEN',
});
}
await createUserIfNotPresent(ctx, input, {
await createUserIfNotPresent(input, {
defaultSettings: {
colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
@@ -36,9 +34,8 @@ export const userRouter = createTRPCRouter({
isOwner: true,
});
}),
count: publicProcedure.query(async ({ ctx }) => {
const count = await ctx.prisma.user.count();
return count;
count: publicProcedure.query(async () => {
return await getTotalUserCountAsync();
}),
createFromInvite: publicProcedure
.input(
@@ -49,51 +46,29 @@ export const userRouter = createTRPCRouter({
)
)
.mutation(async ({ ctx, input }) => {
const token = await ctx.prisma.invite.findUnique({
where: {
token: input.inviteToken,
},
const invite = await db.query.invites.findFirst({
where: eq(invites.token, input.inviteToken),
});
if (!token || token.expires < new Date()) {
if (!invite || invite.expires < new Date()) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Invalid invite token',
});
}
await createUserIfNotPresent(ctx, input, {
const userId = await createUserIfNotPresent(input, {
defaultSettings: {
colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
},
});
const salt = bcrypt.genSaltSync(10);
const hashedPassword = hashPassword(input.password, salt);
const user = await ctx.prisma.user.create({
data: {
name: input.username,
password: hashedPassword,
salt: salt,
settings: {
create: {
colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
},
},
},
});
await ctx.prisma.invite.delete({
where: {
id: token.id,
},
});
await db.delete(invites).where(eq(invites.id, invite.id));
return {
id: user.id,
name: user.name,
id: userId,
name: input.username,
};
}),
changeColorScheme: protectedProcedure
@@ -103,18 +78,12 @@ export const userRouter = createTRPCRouter({
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.user.update({
where: {
id: ctx.session?.user?.id,
},
data: {
settings: {
update: {
colorScheme: input.colorScheme,
},
},
},
});
await db
.update(userSettings)
.set({
colorScheme: input.colorScheme,
})
.where(eq(userSettings.userId, ctx.session?.user?.id));
}),
changeRole: adminProcedure
.input(z.object({ id: z.string(), type: z.enum(['promote', 'demote']) }))
@@ -126,10 +95,8 @@ export const userRouter = createTRPCRouter({
});
}
const user = await ctx.prisma.user.findUnique({
where: {
id: input.id,
},
const user = await db.query.users.findFirst({
where: eq(users.id, input.id),
});
if (!user) {
@@ -146,14 +113,10 @@ export const userRouter = createTRPCRouter({
});
}
await ctx.prisma.user.update({
where: {
id: input.id,
},
data: {
isAdmin: input.type === 'promote',
},
});
await db
.update(users)
.set({ isAdmin: input.type === 'promote' })
.where(eq(users.id, input.id));
}),
changeLanguage: protectedProcedure
.input(
@@ -162,25 +125,15 @@ export const userRouter = createTRPCRouter({
})
)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.user.update({
where: {
id: ctx.session?.user?.id,
},
data: {
settings: {
update: {
language: input.language,
},
},
},
});
await db
.update(userSettings)
.set({ language: input.language })
.where(eq(userSettings.userId, ctx.session?.user?.id));
}),
withSettings: protectedProcedure.query(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({
where: {
id: ctx.session?.user?.id,
},
include: {
withSettings: protectedProcedure.query(async ({ ctx }) => {
const user = await db.query.users.findFirst({
where: eq(users.id, ctx.session?.user?.id),
with: {
settings: true,
},
});
@@ -195,50 +148,26 @@ export const userRouter = createTRPCRouter({
return {
id: user.id,
name: user.name,
settings: {
...user.settings,
firstDayOfWeek: z
.enum(['monday', 'saturday', 'sunday'])
.parse(user.settings.firstDayOfWeek),
},
settings: user.settings,
};
}),
updateSettings: protectedProcedure
.input(updateSettingsValidationSchema)
.mutation(async ({ ctx, input }) => {
await ctx.prisma.user.update({
where: {
id: ctx.session.user.id,
},
data: {
settings: {
update: {
disablePingPulse: input.disablePingPulse,
replacePingWithIcons: input.replaceDotsWithIcons,
defaultBoard: input.defaultBoard,
language: input.language,
firstDayOfWeek: input.firstDayOfWeek,
searchTemplate: input.searchTemplate,
openSearchInNewTab: input.openSearchInNewTab,
autoFocusSearch: input.autoFocusSearch,
},
},
},
});
await db
.update(userSettings)
.set(input)
.where(eq(userSettings.userId, ctx.session?.user?.id));
}),
makeDefaultDashboard: protectedProcedure
.input(z.object({ board: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.userSettings.update({
where: {
userId: ctx.session?.user.id,
},
data: {
defaultBoard: input.board,
},
});
await db
.update(userSettings)
.set({ defaultBoard: input.board })
.where(eq(userSettings.userId, ctx.session?.user?.id));
}),
all: adminProcedure
@@ -254,26 +183,20 @@ export const userRouter = createTRPCRouter({
)
.query(async ({ ctx, input }) => {
const limit = input.limit;
const users = await ctx.prisma.user.findMany({
take: limit + 1,
skip: limit * input.page,
where: {
name: {
contains: input.search,
},
},
const dbUsers = await db.query.users.findMany({
limit: limit + 1,
offset: limit * input.page,
where: input.search ? like(users.name, `%${input.search}%`) : undefined,
});
const countUsers = await ctx.prisma.user.count({
where: {
name: {
contains: input.search,
},
},
});
const countUsers = await db
.select({ count: sql<number>`count(*)` })
.from(users)
.where(input.search ? like(users.name, `%${input.search}%`) : undefined)
.then((rows) => rows[0].count);
return {
users: users.map((user) => ({
users: dbUsers.map((user) => ({
id: user.id,
name: user.name!,
email: user.email,
@@ -284,7 +207,7 @@ export const userRouter = createTRPCRouter({
};
}),
create: adminProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => {
await createUserIfNotPresent(ctx, input);
await createUserIfNotPresent(input);
}),
deleteUser: adminProcedure
@@ -294,10 +217,8 @@ export const userRouter = createTRPCRouter({
})
)
.mutation(async ({ ctx, input }) => {
const user = await ctx.prisma.user.findUnique({
where: {
id: input.id,
},
const user = await db.query.users.findFirst({
where: eq(users.id, input.id),
});
if (!user) {
@@ -320,26 +241,19 @@ export const userRouter = createTRPCRouter({
});
}
await ctx.prisma.user.delete({
where: {
id: input.id,
},
});
await db.delete(users).where(eq(users.id, input.id));
}),
});
const createUserIfNotPresent = async (
ctx: TRPCContext,
input: z.infer<typeof createNewUserSchema>,
options: {
defaultSettings?: Partial<UserSettings>;
isOwner?: boolean;
} | void
) => {
const existingUser = await ctx.prisma.user.findFirst({
where: {
name: input.username,
},
const existingUser = await db.query.users.findFirst({
where: eq(users.name, input.username),
});
if (existingUser) {
@@ -351,17 +265,22 @@ const createUserIfNotPresent = async (
const salt = bcrypt.genSaltSync(10);
const hashedPassword = hashPassword(input.password, salt);
await ctx.prisma.user.create({
data: {
name: input.username,
email: input.email,
password: hashedPassword,
salt: salt,
isAdmin: options?.isOwner ?? false,
isOwner: options?.isOwner ?? false,
settings: {
create: options?.defaultSettings ?? {},
},
},
const userId = randomUUID();
await db.insert(users).values({
id: userId,
name: input.username,
email: input.email,
password: hashedPassword,
salt: salt,
isAdmin: options?.isOwner ?? false,
isOwner: options?.isOwner ?? false,
});
await db.insert(userSettings).values({
id: randomUUID(),
userId,
...(options?.defaultSettings ?? {}),
});
return userId;
};

View File

@@ -13,7 +13,6 @@ import superjson from 'superjson';
import { ZodError } from 'zod';
import { getServerAuthSession } from '../auth';
import { prisma } from '../db';
/**
* 1. CONTEXT
@@ -41,7 +40,6 @@ interface CreateContextOptions {
const createInnerTRPCContext = (opts: CreateContextOptions) => ({
session: opts.session,
cookies: opts.cookies,
prisma,
});
export type TRPCContext = ReturnType<typeof createInnerTRPCContext>;

View File

@@ -1,17 +1,20 @@
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import bcrypt from 'bcryptjs';
import Consola from 'consola';
import Cookies from 'cookies';
import { eq } from 'drizzle-orm';
import { type GetServerSidePropsContext, type NextApiRequest, type NextApiResponse } from 'next';
import { type DefaultSession, type NextAuthOptions, getServerSession } from 'next-auth';
import { Adapter } from 'next-auth/adapters';
import { decode, encode } from 'next-auth/jwt';
import Credentials from 'next-auth/providers/credentials';
import { prisma } from '~/server/db';
import EmptyNextAuthProvider from '~/utils/empty-provider';
import { fromDate, generateSessionToken } from '~/utils/session';
import { colorSchemeParser, signInSchema } from '~/validations/user';
import { db } from './db';
import { users } from './db/schema';
/**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
* object and keep type safety.
@@ -48,7 +51,7 @@ declare module 'next-auth/jwt' {
}
}
const adapter = PrismaAdapter(prisma);
const adapter = DrizzleAdapter(db);
const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
/**
@@ -68,25 +71,25 @@ export const constructAuthOptions = (
// eslint-disable-next-line no-param-reassign
session.user.name = user.name as string;
const userFromDatabase = await prisma.user.findUniqueOrThrow({
where: {
id: user.id,
},
include: {
const userFromDatabase = await db.query.users.findFirst({
with: {
settings: {
select: {
columns: {
colorScheme: true,
language: true,
autoFocusSearch: true,
},
},
},
where: eq(users.id, user.id),
});
session.user.isAdmin = userFromDatabase.isAdmin;
session.user.colorScheme = colorSchemeParser.parse(userFromDatabase.settings?.colorScheme);
session.user.language = userFromDatabase.settings?.language ?? 'en';
session.user.autoFocusSearch = userFromDatabase.settings?.autoFocusSearch ?? false;
session.user.isAdmin = userFromDatabase?.isAdmin ?? false;
session.user.colorScheme = userFromDatabase
? colorSchemeParser.parse(userFromDatabase.settings?.colorScheme)
: 'environment';
session.user.language = userFromDatabase?.settings?.language ?? 'en';
session.user.autoFocusSearch = userFromDatabase?.settings?.autoFocusSearch ?? false;
}
return session;
@@ -129,7 +132,7 @@ export const constructAuthOptions = (
signIn: '/auth/login',
error: '/auth/login',
},
adapter: PrismaAdapter(prisma),
adapter: adapter as Adapter,
providers: [
Credentials({
name: 'credentials',
@@ -143,19 +146,17 @@ export const constructAuthOptions = (
async authorize(credentials) {
const data = await signInSchema.parseAsync(credentials);
const user = await prisma.user.findFirst({
where: {
name: data.name,
},
include: {
const user = await db.query.users.findFirst({
with: {
settings: {
select: {
columns: {
colorScheme: true,
language: true,
autoFocusSearch: true,
},
},
},
where: eq(users.name, data.name),
});
if (!user || !user.password) {

View File

@@ -1,14 +0,0 @@
import { PrismaClient } from '@prisma/client';
import { env } from '~/env';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: env.NEXT_PUBLIC_NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (env.NEXT_PUBLIC_NODE_ENV !== 'production') globalForPrisma.prisma = prisma;

9
src/server/db/index.ts Normal file
View File

@@ -0,0 +1,9 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { env } from '~/env';
import * as schema from './schema';
const sqlite = new Database(env.DATABASE_URL?.replace('file:', ''));
export const db = drizzle(sqlite, { schema });

View File

@@ -0,0 +1,11 @@
import { sql } from 'drizzle-orm';
import { db } from '..';
import { users } from '../schema';
export const getTotalUserCountAsync = async () => {
return await db
.select({ count: sql<number>`count(*)` })
.from(users)
.then((rows) => rows[0].count);
};

View File

@@ -0,0 +1,18 @@
import { eq } from 'drizzle-orm';
import { db } from '..';
import { userSettings } from '../schema';
export const getDefaultBoardAsync = async (
userId: string | undefined,
fallback: string = 'default'
) => {
if (!userId) {
return fallback;
}
return await db.query.userSettings
.findFirst({
where: eq(userSettings.userId, userId),
})
.then((settings) => settings?.defaultBoard ?? fallback);
};

133
src/server/db/schema.ts Normal file
View File

@@ -0,0 +1,133 @@
import { InferSelectModel, relations } from 'drizzle-orm';
import { index, int, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { type AdapterAccount } from 'next-auth/adapters';
export const users = sqliteTable('user', {
id: text('id').notNull().primaryKey(),
name: text('name'),
email: text('email'),
emailVerified: integer('emailVerified', { mode: 'timestamp_ms' }),
image: text('image'),
password: text('password'),
salt: text('salt'),
isAdmin: int('is_admin', { mode: 'boolean' }).notNull().default(false),
isOwner: int('is_owner', { mode: 'boolean' }).notNull().default(false),
});
export const accounts = sqliteTable(
'account',
{
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<AdapterAccount['type']>().notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: integer('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state'),
},
(account) => ({
compoundKey: primaryKey(account.provider, account.providerAccountId),
userIdIdx: index('userId_idx').on(account.userId),
})
);
export const sessions = sqliteTable(
'session',
{
sessionToken: text('sessionToken').notNull().primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: integer('expires', { mode: 'timestamp_ms' }).notNull(),
},
(session) => ({
userIdIdx: index('user_id_idx').on(session.userId),
})
);
export const verificationTokens = sqliteTable(
'verificationToken',
{
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: integer('expires', { mode: 'timestamp_ms' }).notNull(),
},
(vt) => ({
compoundKey: primaryKey(vt.identifier, vt.token),
})
);
const validColorScheme = ['environment', 'light', 'dark'] as const;
type ValidColorScheme = (typeof validColorScheme)[number];
const firstDaysOfWeek = ['monday', 'saturday', 'sunday'] as const;
type ValidFirstDayOfWeek = (typeof firstDaysOfWeek)[number];
export const userSettings = sqliteTable('user_setting', {
id: text('id').notNull().primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
colorScheme: text('color_scheme').$type<ValidColorScheme>().notNull().default('environment'),
language: text('language').notNull().default('en'),
defaultBoard: text('default_board').notNull().default('default'),
firstDayOfWeek: text('first_day_of_week')
.$type<ValidFirstDayOfWeek>()
.notNull()
.default('monday'),
searchTemplate: text('search_template').notNull().default('https://google.com/search?q=%s'),
openSearchInNewTab: int('open_search_in_new_tab', { mode: 'boolean' }).notNull().default(true),
disablePingPulse: int('disable_ping_pulse', { mode: 'boolean' }).notNull().default(false),
replacePingWithIcons: int('replace_ping_with_icons', { mode: 'boolean' })
.notNull()
.default(false),
useDebugLanguage: int('use_debug_language', { mode: 'boolean' }).notNull().default(false),
autoFocusSearch: int('auto_focus_search', { mode: 'boolean' }).notNull().default(false),
});
export type UserSettings = InferSelectModel<typeof userSettings>;
export const invites = sqliteTable('invite', {
id: text('id').notNull().primaryKey(),
token: text('token').notNull().unique(),
expires: int('expires', {
mode: 'timestamp',
}).notNull(),
createdById: text('created_by_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
});
export type Invite = InferSelectModel<typeof invites>;
export const accountRelations = relations(accounts, ({ one }) => ({
user: one(users, {
fields: [accounts.userId],
references: [users.id],
}),
}));
export const userRelations = relations(users, ({ many, one }) => ({
accounts: many(accounts),
settings: one(userSettings),
invites: many(invites),
}));
export const userSettingRelations = relations(userSettings, ({ one }) => ({
user: one(users, {
fields: [userSettings.userId],
references: [users.id],
}),
}));
export const inviteRelations = relations(invites, ({ one }) => ({
createdBy: one(users, {
fields: [invites.createdById],
references: [users.id],
}),
}));