From 44c16cf2deabdede2ac0e5bd6f008af3512f0358 Mon Sep 17 00:00:00 2001 From: Gerard Clos Date: Sat, 20 Jul 2024 19:26:02 +0200 Subject: [PATCH] feature: tenancy --- apps/web/src/actions/documents/create.ts | 7 +- apps/web/src/actions/procedures/index.ts | 10 +- apps/web/src/actions/user/setupAction.ts | 1 - .../src/app/(private)/_data-access/index.ts | 30 ++-- .../[...documentPath]/route.test.ts | 6 +- .../[commitUuid]/[...documentPath]/route.ts | 13 +- apps/web/src/app/(private)/page.tsx | 4 +- .../(private)/projects/[projectId]/page.tsx | 4 +- .../versions/[commitUuid]/layout.tsx | 27 ++-- .../commits/[commitUuid]/documents/route.ts | 11 +- apps/web/src/components/Sidebar/index.tsx | 13 +- apps/web/src/data-access/users.ts | 7 +- apps/web/src/middleware.ts | 4 +- packages/core/src/data-access/apiKeys.ts | 9 +- packages/core/src/data-access/commits.ts | 82 ---------- .../documentVersions/getDocumentById.ts | 20 --- .../documentVersions/getDocumentByPath.ts | 34 ---- .../documentVersions/getDocumentsAtCommit.ts | 93 ----------- .../src/data-access/documentVersions/index.ts | 3 - packages/core/src/data-access/index.ts | 5 +- packages/core/src/data-access/projects.ts | 47 ------ packages/core/src/data-access/users.ts | 2 +- packages/core/src/index.ts | 1 + .../src/repositories/commitsRepository.ts | 108 +++++++++++++ .../documentVersionsRepository.ts | 153 ++++++++++++++++++ .../getDocumentsAtCommit.test.ts | 38 +++-- packages/core/src/repositories/index.ts | 3 + .../src/repositories/projectsRepository.ts | 39 +++++ packages/core/src/repositories/repository.ts | 14 ++ packages/core/src/services/commits/merge.ts | 33 ++-- .../src/services/documentVersions/create.ts | 13 +- packages/core/src/services/projects/create.ts | 9 +- .../core/src/tests/factories/documents.ts | 10 +- packages/core/src/tests/factories/projects.ts | 4 +- 34 files changed, 462 insertions(+), 395 deletions(-) delete mode 100644 packages/core/src/data-access/commits.ts delete mode 100644 packages/core/src/data-access/documentVersions/getDocumentById.ts delete mode 100644 packages/core/src/data-access/documentVersions/getDocumentByPath.ts delete mode 100644 packages/core/src/data-access/documentVersions/getDocumentsAtCommit.ts delete mode 100644 packages/core/src/data-access/documentVersions/index.ts delete mode 100644 packages/core/src/data-access/projects.ts create mode 100644 packages/core/src/repositories/commitsRepository.ts create mode 100644 packages/core/src/repositories/documentVersionsRepository.ts rename packages/core/src/{data-access/documentVersions => repositories}/getDocumentsAtCommit.test.ts (80%) create mode 100644 packages/core/src/repositories/index.ts create mode 100644 packages/core/src/repositories/projectsRepository.ts create mode 100644 packages/core/src/repositories/repository.ts diff --git a/apps/web/src/actions/documents/create.ts b/apps/web/src/actions/documents/create.ts index 032acdc05..bca0aa53f 100644 --- a/apps/web/src/actions/documents/create.ts +++ b/apps/web/src/actions/documents/create.ts @@ -14,7 +14,10 @@ export const createDocumentVersionAction = withProject }), { type: 'json' }, ) - .handler(async ({ input }) => { - const result = await createDocumentVersion(input) + .handler(async ({ input, ctx }) => { + const result = await createDocumentVersion({ + ...input, + project: ctx.project, + }) return result.unwrap() }) diff --git a/apps/web/src/actions/procedures/index.ts b/apps/web/src/actions/procedures/index.ts index 70c25bc8e..4a61fff93 100644 --- a/apps/web/src/actions/procedures/index.ts +++ b/apps/web/src/actions/procedures/index.ts @@ -1,4 +1,4 @@ -import { findProject } from '@latitude-data/core' +import { ProjectsRepository } from '@latitude-data/core' import { getCurrentUser } from '$/services/auth/getCurrentUser' import { z } from 'zod' import { createServerActionProcedure } from 'zsa' @@ -16,11 +16,11 @@ export const authProcedure = createServerActionProcedure().handler(async () => { export const withProject = createServerActionProcedure(authProcedure) .input(z.object({ projectId: z.number() })) .handler(async ({ input, ctx }) => { + const { workspace } = ctx + const projectScope = new ProjectsRepository(workspace.id) const project = ( - await findProject({ - projectId: input.projectId, - workspaceId: ctx.workspace.id, - }) + await projectScope.getProjectById(input.projectId) ).unwrap() + return { ...ctx, project } }) diff --git a/apps/web/src/actions/user/setupAction.ts b/apps/web/src/actions/user/setupAction.ts index 48588a4d5..1d8570d22 100644 --- a/apps/web/src/actions/user/setupAction.ts +++ b/apps/web/src/actions/user/setupAction.ts @@ -24,7 +24,6 @@ export const setupAction = createServerAction() ) .handler(async ({ input }) => { const itWasAlreadySetup = await isWorkspaceCreated() - if (itWasAlreadySetup) { throw new Error('Workspace already created') } diff --git a/apps/web/src/app/(private)/_data-access/index.ts b/apps/web/src/app/(private)/_data-access/index.ts index 389cebf5b..198c000e9 100644 --- a/apps/web/src/app/(private)/_data-access/index.ts +++ b/apps/web/src/app/(private)/_data-access/index.ts @@ -1,16 +1,15 @@ import { cache } from 'react' import { - findCommit as originalfindCommit, - findProject as originalFindProject, - getFirstProject as originalGetFirstProject, - type FindCommitProps, - type FindProjectProps, + CommitsRepository, + Project, + ProjectsRepository, } from '@latitude-data/core' export const getFirstProject = cache( async ({ workspaceId }: { workspaceId: number }) => { - const result = await originalGetFirstProject({ workspaceId }) + const projectsScope = new ProjectsRepository(workspaceId) + const result = await projectsScope.getFirstProject() const project = result.unwrap() return project @@ -18,8 +17,15 @@ export const getFirstProject = cache( ) export const findProject = cache( - async ({ projectId, workspaceId }: FindProjectProps) => { - const result = await originalFindProject({ projectId, workspaceId }) + async ({ + projectId, + workspaceId, + }: { + projectId: number + workspaceId: number + }) => { + const projectsScope = new ProjectsRepository(workspaceId) + const result = await projectsScope.getProjectById(projectId) const project = result.unwrap() return project @@ -27,8 +33,12 @@ export const findProject = cache( ) export const findCommit = cache( - async ({ uuid, projectId }: FindCommitProps) => { - const result = await originalfindCommit({ uuid, projectId }) + async ({ uuid, project }: { uuid: string; project: Project }) => { + const commitsScope = new CommitsRepository(project.workspaceId) + const result = await commitsScope.getCommitByUuid({ + projectId: project.id, + uuid, + }) const commit = result.unwrap() return commit diff --git a/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.test.ts b/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.test.ts index 229e7da5d..0449d30a1 100644 --- a/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.test.ts +++ b/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.test.ts @@ -15,7 +15,7 @@ describe('GET documentVersion', () => { commit, }) - await mergeCommit({ commitId: commit.id }) + await mergeCommit(commit) const response = await GET( new NextRequest( @@ -41,7 +41,7 @@ describe('GET documentVersion', () => { commit, }) - await mergeCommit({ commitId: commit.id }) + await mergeCommit(commit) const response = await GET( new NextRequest( @@ -64,7 +64,7 @@ describe('GET documentVersion', () => { const { project } = await ctx.factories.createProject() const { commit } = await ctx.factories.createDraft({ project }) - await mergeCommit({ commitId: commit.id }) + await mergeCommit(commit) const response = await GET( new NextRequest( diff --git a/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.ts b/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.ts index bcbd259c7..a2067cc98 100644 --- a/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.ts +++ b/apps/web/src/app/(private)/api/v1/projects/[projectId]/commits/[commitUuid]/[...documentPath]/route.ts @@ -1,9 +1,9 @@ -import { getDocumentByPath } from '@latitude-data/core' +import { DocumentVersionsRepository } from '@latitude-data/core' import apiRoute from '$/helpers/api/route' -import { NextRequest } from 'next/server' +import { LatitudeRequest } from '$/middleware' export async function GET( - _: NextRequest, + req: LatitudeRequest, { params, }: { @@ -15,11 +15,12 @@ export async function GET( }, ) { return apiRoute(async () => { + const workspaceId = req.workspaceId! const { projectId, commitUuid, documentPath } = params - - const result = await getDocumentByPath({ - projectId: Number(projectId), + const documentVersionsScope = new DocumentVersionsRepository(workspaceId) + const result = await documentVersionsScope.getDocumentByPath({ commitUuid, + projectId: Number(projectId), path: documentPath.join('/'), }) const document = result.unwrap() diff --git a/apps/web/src/app/(private)/page.tsx b/apps/web/src/app/(private)/page.tsx index 5f57c6971..66b548e30 100644 --- a/apps/web/src/app/(private)/page.tsx +++ b/apps/web/src/app/(private)/page.tsx @@ -15,7 +15,9 @@ export default async function AppRoot() { try { session = await getCurrentUser() project = await getFirstProject({ workspaceId: session.workspace.id }) - await findCommit({ uuid: HEAD_COMMIT, projectId: project.id }) + + await findCommit({ uuid: HEAD_COMMIT, project }) + url = PROJECT_ROUTE({ id: project.id }).commits.latest } catch (error) { if (error instanceof NotFoundError) { diff --git a/apps/web/src/app/(private)/projects/[projectId]/page.tsx b/apps/web/src/app/(private)/projects/[projectId]/page.tsx index 428460402..0cc279af0 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/page.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/page.tsx @@ -18,10 +18,10 @@ export default async function ProjectPage({ params }: ProjectPageParams) { try { session = await getCurrentUser() project = await findProject({ - projectId: params.projectId, + projectId: Number(params.projectId), workspaceId: session.workspace.id, }) - await findCommit({ uuid: HEAD_COMMIT, projectId: project.id }) + await findCommit({ uuid: HEAD_COMMIT, project }) url = PROJECT_ROUTE({ id: +project.id }).commits.latest } catch (error) { if (error instanceof NotFoundError) { diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/layout.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/layout.tsx index 7db261a82..7f5a831a4 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/layout.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/layout.tsx @@ -2,13 +2,14 @@ import { ReactNode } from 'react' import { Commit, + CommitsRepository, HEAD_COMMIT, NotFoundError, Project, + ProjectsRepository, } from '@latitude-data/core' import { CommitProvider, ProjectProvider } from '@latitude-data/web-ui' import { AppLayout, BreadcrumpBadge } from '@latitude-data/web-ui/browser' -import { findCommit, findProject } from '$/app/(private)/_data-access' import { NAV_LINKS } from '$/app/(private)/_lib/constants' import { ProjectPageParams } from '$/app/(private)/projects/[projectId]/page' import { getCurrentUser, SessionData } from '$/services/auth/getCurrentUser' @@ -29,18 +30,20 @@ export default async function CommitLayout({ let commit: Commit try { session = await getCurrentUser() - project = await findProject({ - projectId: params.projectId, - workspaceId: session.workspace.id, - }) - commit = await findCommit({ - uuid: params.commitUuid, - projectId: project.id, - }) + const projectsRepo = new ProjectsRepository(session.workspace.id) + const commitsRepo = new CommitsRepository(session.workspace.id) + project = ( + await projectsRepo.getProjectById(Number(params.projectId)) + ).unwrap() + commit = ( + await commitsRepo.getCommitByUuid({ + uuid: params.commitUuid, + projectId: project.id, + }) + ).unwrap() } catch (error) { - if (error instanceof NotFoundError) { - return notFound() - } + if (error instanceof NotFoundError) return notFound() + throw error } diff --git a/apps/web/src/app/api/projects/[projectId]/commits/[commitUuid]/documents/route.ts b/apps/web/src/app/api/projects/[projectId]/commits/[commitUuid]/documents/route.ts index 4d195ad15..a8ad827da 100644 --- a/apps/web/src/app/api/projects/[projectId]/commits/[commitUuid]/documents/route.ts +++ b/apps/web/src/app/api/projects/[projectId]/commits/[commitUuid]/documents/route.ts @@ -1,14 +1,17 @@ -import { getDocumentsAtCommit } from '@latitude-data/core' -import { NextRequest, NextResponse } from 'next/server' +import { DocumentVersionsRepository } from '@latitude-data/core' +import { LatitudeRequest } from '$/middleware' +import { NextResponse } from 'next/server' export async function GET( - _: NextRequest, + req: LatitudeRequest, { params: { commitUuid, projectId }, }: { params: { commitUuid: string; projectId: number } }, ) { try { - const documents = await getDocumentsAtCommit({ + const workspaceId = req.workspaceId! + const scope = new DocumentVersionsRepository(workspaceId) + const documents = await scope.getDocumentsAtCommit({ commitUuid, projectId: Number(projectId), }) diff --git a/apps/web/src/components/Sidebar/index.tsx b/apps/web/src/components/Sidebar/index.tsx index ff92b79b3..528c3ac3f 100644 --- a/apps/web/src/components/Sidebar/index.tsx +++ b/apps/web/src/components/Sidebar/index.tsx @@ -1,16 +1,19 @@ -import { getDocumentsAtCommit } from '@latitude-data/core' +import { DocumentVersionsRepository, Project } from '@latitude-data/core' import DocumentTree, { CreateNode } from './DocumentTree' export default async function Sidebar({ commitUuid, - projectId, + project, }: { commitUuid: string - projectId: number + project: Project }) { - const documentsResult = await getDocumentsAtCommit({ - projectId, + const documentVersionsScope = new DocumentVersionsRepository( + project.workspaceId, + ) + const documentsResult = await documentVersionsScope.getDocumentsAtCommit({ + projectId: project.id, commitUuid, }) const documents = documentsResult.unwrap() diff --git a/apps/web/src/data-access/users.ts b/apps/web/src/data-access/users.ts index a5f474478..d19c5c039 100644 --- a/apps/web/src/data-access/users.ts +++ b/apps/web/src/data-access/users.ts @@ -1,6 +1,6 @@ import { database, - getUser, + unsafelyGetUser, NotFoundError, Result, users, @@ -31,15 +31,12 @@ export async function getUserFromCredentials({ }, where: eq(users.email, email), }) - if (!user) return notFound() const validPassword = await verifyPassword(password, user.encryptedPassword) - if (!validPassword) notFound() const wpResult = await getWorkspace({ userId: user.id }) - if (wpResult.error) { return Result.error(wpResult.error) } @@ -60,7 +57,7 @@ export async function getCurrentUserFromDB({ }: { userId: string | undefined }): PromisedResult { - const user = await getUser(userId) + const user = await unsafelyGetUser(userId) if (!user) return notFound() const wpResult = await getWorkspace({ userId: user.id }) diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index d1a713e89..8bf0ca80f 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -1,4 +1,4 @@ -import { getApiKey } from '@latitude-data/core' +import { unsafelyGetApiKey } from '@latitude-data/core' import { NextRequest, NextResponse } from 'next/server' import env from './env' @@ -15,7 +15,7 @@ export async function middleware(request: LatitudeRequest) { return apiUnauthorized() } - const result = await getApiKey({ uuid: token }) + const result = await unsafelyGetApiKey({ uuid: token }) if (result.error) return apiUnauthorized() request.workspaceId = result.value.workspaceId diff --git a/packages/core/src/data-access/apiKeys.ts b/packages/core/src/data-access/apiKeys.ts index 54445e9d8..1c4984123 100644 --- a/packages/core/src/data-access/apiKeys.ts +++ b/packages/core/src/data-access/apiKeys.ts @@ -3,8 +3,13 @@ import { NotFoundError, Result } from '$core/lib' import { apiKeys } from '$core/schema' import { eq } from 'drizzle-orm' -export async function getApiKey({ uuid }: { uuid: string }, db = database) { - const apiKey = await db.query.apiKeys.findFirst({ where: eq(apiKeys, uuid) }) +export async function unsafelyGetApiKey( + { uuid }: { uuid: string }, + db = database, +) { + const apiKey = await db.query.apiKeys.findFirst({ + where: eq(apiKeys.uuid, uuid), + }) if (!apiKey) return Result.error(new NotFoundError('API key not found')) return Result.ok(apiKey) diff --git a/packages/core/src/data-access/commits.ts b/packages/core/src/data-access/commits.ts deleted file mode 100644 index 613e5a562..000000000 --- a/packages/core/src/data-access/commits.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { database } from '$core/client' -import { HEAD_COMMIT } from '$core/constants' -import { Result, TypedResult } from '$core/lib' -import { LatitudeError, NotFoundError } from '$core/lib/errors' -import { Commit, commits } from '$core/schema' -import { and, desc, eq, isNotNull } from 'drizzle-orm' - -export async function findHeadCommit( - { projectId }: { projectId: number }, - tx = database, -): Promise> { - const result = await tx - .select() - .from(commits) - .where(and(isNotNull(commits.mergedAt), eq(commits.projectId, projectId))) - .orderBy(desc(commits.mergedAt)) - .limit(1) - - if (result.length < 1) { - return Result.error(new NotFoundError('No head commit found')) - } - - const headCommit = result[0]! - return Result.ok(headCommit) -} - -export type FindCommitProps = { - uuid: string - projectId?: number -} -export async function findCommit( - { projectId, uuid }: FindCommitProps, - tx = database, -): Promise> { - if (uuid === HEAD_COMMIT) { - if (!projectId) { - return Result.error(new NotFoundError('Project ID is required')) - } - - return findHeadCommit({ projectId }, tx) - } - - const commit = await tx.query.commits.findFirst({ - where: eq(commits.uuid, uuid), - }) - - if (!commit) return Result.error(new NotFoundError('Commit not found')) - - return Result.ok(commit) -} - -export async function listCommits() { - return database.select().from(commits) -} - -export async function getCommitMergedAt( - { projectId, commitUuid }: { projectId: number; commitUuid: string }, - tx = database, -): Promise> { - if (commitUuid === HEAD_COMMIT) { - const result = await tx - .select({ mergedAt: commits.mergedAt }) - .from(commits) - .where(and(eq(commits.projectId, projectId), isNotNull(commits.mergedAt))) - .orderBy(desc(commits.mergedAt)) - .limit(1) - - if (!result.length) { - return Result.error(new NotFoundError('No head commit found')) - } - const headCommit = result[0]! - return Result.ok(headCommit.mergedAt!) - } - - const commit = await tx.query.commits.findFirst({ - where: eq(commits.uuid, commitUuid), - }) - - if (!commit) return Result.error(new NotFoundError('Commit not found')) - - return Result.ok(commit.mergedAt) -} diff --git a/packages/core/src/data-access/documentVersions/getDocumentById.ts b/packages/core/src/data-access/documentVersions/getDocumentById.ts deleted file mode 100644 index 003bb0735..000000000 --- a/packages/core/src/data-access/documentVersions/getDocumentById.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { database } from '$core/client' -import { LatitudeError, NotFoundError, Result, TypedResult } from '$core/lib' -import { documentVersions } from '$core/schema' -import { eq } from 'drizzle-orm' - -export async function getDocumentById( - { - documentId, - }: { - documentId: number - }, - db = database, -): Promise> { - const document = await db.query.documentVersions.findFirst({ - where: eq(documentVersions.id, documentId), - }) - if (!document) return Result.error(new NotFoundError('Document not found')) - - return Result.ok(document) -} diff --git a/packages/core/src/data-access/documentVersions/getDocumentByPath.ts b/packages/core/src/data-access/documentVersions/getDocumentByPath.ts deleted file mode 100644 index ca48c355f..000000000 --- a/packages/core/src/data-access/documentVersions/getDocumentByPath.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { database } from '$core/client' -import { NotFoundError, Result } from '$core/lib' - -import { getDocumentsAtCommit } from './getDocumentsAtCommit' - -export async function getDocumentByPath( - { - projectId, - commitUuid, - path, - }: { - projectId: number - commitUuid: string - path: string - }, - db = database, -) { - try { - const result = await getDocumentsAtCommit({ projectId, commitUuid }, db) - const documents = result.unwrap() - const document = documents.find((doc) => doc.path === path) - if (!document) { - return Result.error( - new NotFoundError( - `No document with path ${path} at commit ${commitUuid}`, - ), - ) - } - - return Result.ok(document) - } catch (err) { - return Result.error(err as Error) - } -} diff --git a/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.ts b/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.ts deleted file mode 100644 index d0e186a24..000000000 --- a/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { - commits, - database, - DocumentVersion, - documentVersions, - findCommit, - getCommitMergedAt, - Result, - TypedResult, -} from '@latitude-data/core' -import { LatitudeError } from '$core/lib/errors' -import { and, eq, isNotNull, lte, max } from 'drizzle-orm' - -export async function getDocumentsAtCommit( - { commitUuid, projectId }: { commitUuid: string; projectId: number }, - tx = database, -): Promise> { - const maxMergedAtResult = await getCommitMergedAt({ commitUuid, projectId }) - if (maxMergedAtResult.error) return maxMergedAtResult - const maxMergedAt = maxMergedAtResult.unwrap() - - const whereStatement = () => { - const mergedAtNotNull = isNotNull(commits.mergedAt) - if (!maxMergedAt) { - return mergedAtNotNull - } - return and(mergedAtNotNull, lte(commits.mergedAt, maxMergedAt)) - } - - const lastVersionOfEachDocument = tx.$with('lastVersionOfDocuments').as( - tx - .select({ - documentUuid: documentVersions.documentUuid, - mergedAt: max(commits.mergedAt).as('maxMergedAt'), - }) - .from(documentVersions) - .innerJoin(commits, eq(commits.id, documentVersions.commitId)) - .where(whereStatement()) - .groupBy(documentVersions.documentUuid), - ) - - const documentsAtPreviousMergedCommitsResult = await tx - .with(lastVersionOfEachDocument) - .select() - .from(documentVersions) - .innerJoin( - commits, - and( - eq(commits.id, documentVersions.commitId), - isNotNull(commits.mergedAt), - ), - ) - .innerJoin( - lastVersionOfEachDocument, - and( - eq( - documentVersions.documentUuid, - lastVersionOfEachDocument.documentUuid, - ), - eq(commits.mergedAt, lastVersionOfEachDocument.mergedAt), - ), - ) - - const documentsAtPreviousMergedCommits = - documentsAtPreviousMergedCommitsResult.map((d) => d.document_versions) - - if (maxMergedAt) { - // Referenced commit is merged. No additional documents to return. - return Result.ok(documentsAtPreviousMergedCommits) - } - - const commitResult = await findCommit({ projectId, uuid: commitUuid }) - if (commitResult.error) return commitResult - - const commit = commitResult.unwrap() - - const documentsAtDraftResult = await tx - .select() - .from(documentVersions) - .innerJoin(commits, eq(commits.id, documentVersions.commitId)) - .where(eq(commits.id, commit.id)) - - const documentsAtDraft = documentsAtDraftResult.map( - (d) => d.document_versions, - ) - const totalDocuments = documentsAtPreviousMergedCommits - .filter((d) => - documentsAtDraft.find((d2) => d2.documentUuid !== d.documentUuid), - ) - .concat(documentsAtDraft) - - return Result.ok(totalDocuments) -} diff --git a/packages/core/src/data-access/documentVersions/index.ts b/packages/core/src/data-access/documentVersions/index.ts deleted file mode 100644 index 84dbd18ac..000000000 --- a/packages/core/src/data-access/documentVersions/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './getDocumentsAtCommit' -export * from './getDocumentByPath' -export * from './getDocumentById' diff --git a/packages/core/src/data-access/index.ts b/packages/core/src/data-access/index.ts index 952fcc44a..f7d30650c 100644 --- a/packages/core/src/data-access/index.ts +++ b/packages/core/src/data-access/index.ts @@ -1,5 +1,2 @@ -export * from './users' -export * from './projects' -export * from './commits' -export * from './documentVersions' export * from './apiKeys' +export * from './users' diff --git a/packages/core/src/data-access/projects.ts b/packages/core/src/data-access/projects.ts deleted file mode 100644 index 8dc9bd692..000000000 --- a/packages/core/src/data-access/projects.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { database } from '$core/client' -import { Result } from '$core/lib' -import { NotFoundError } from '$core/lib/errors' -import { projects } from '$core/schema' -import { and, eq } from 'drizzle-orm' - -const NOT_FOUND_MSG = 'Project not found' - -export type FindProjectProps = { - projectId: number | string - workspaceId: number -} -export async function findProject({ - projectId, - workspaceId, -}: FindProjectProps) { - const id = Number(projectId) - if (isNaN(id)) { - return Result.error(new NotFoundError(NOT_FOUND_MSG)) - } - - const project = await database.query.projects.findFirst({ - where: and(eq(projects.workspaceId, workspaceId), eq(projects.id, id)), - }) - - if (!project) { - return Result.error(new NotFoundError(NOT_FOUND_MSG)) - } - - return Result.ok(project!) -} - -export async function getFirstProject({ - workspaceId, -}: { - workspaceId: number -}) { - const project = await database.query.projects.findFirst({ - where: eq(projects.workspaceId, workspaceId), - }) - - if (!project) { - return Result.error(new NotFoundError(NOT_FOUND_MSG)) - } - - return Result.ok(project!) -} diff --git a/packages/core/src/data-access/users.ts b/packages/core/src/data-access/users.ts index 1b97787a9..da674ca71 100644 --- a/packages/core/src/data-access/users.ts +++ b/packages/core/src/data-access/users.ts @@ -6,7 +6,7 @@ export type SessionData = { workspace: { id: number; name: string } } -export function getUser(id?: string) { +export function unsafelyGetUser(id?: string) { return database.query.users.findFirst({ where: eq(users.id, id ?? ''), }) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 830fa9395..9e285fb53 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,3 +4,4 @@ export * from './data-access' export * from './lib' export * from './schema' export * from './services' +export * from './repositories' diff --git a/packages/core/src/repositories/commitsRepository.ts b/packages/core/src/repositories/commitsRepository.ts new file mode 100644 index 000000000..df68a755b --- /dev/null +++ b/packages/core/src/repositories/commitsRepository.ts @@ -0,0 +1,108 @@ +import { HEAD_COMMIT } from '$core/constants' +import { NotFoundError, Result } from '$core/lib' +import { commits, projects } from '$core/schema' +import { and, desc, eq, isNotNull } from 'drizzle-orm' + +import Repository from './repository' + +export class CommitsRepository extends Repository { + get scope() { + return this.db + .select() + .from(commits) + .innerJoin(projects, eq(projects.workspaceId, this.workspaceId)) + .where(eq(commits.projectId, projects.id)) + .as('commitsScope') + } + + async getHeadCommit({ projectId }: { projectId: number }) { + const result = await this.db + .select() + .from(this.scope) + .where(and(isNotNull(commits.mergedAt), eq(commits.projectId, projectId))) + .orderBy(desc(commits.mergedAt)) + .limit(1) + + if (result.length < 1) { + return Result.error(new NotFoundError('No head commit found')) + } + + return Result.ok(result[0]!.commits) + } + + async getCommitByUuid({ + uuid, + projectId, + }: { + projectId?: number + uuid: string + }) { + if (uuid === HEAD_COMMIT) { + if (!projectId) { + return Result.error(new NotFoundError('Project ID is required')) + } + + return this.getHeadCommit({ projectId }) + } + + const result = await this.db + .select() + .from(this.scope) + .where(eq(commits.uuid, uuid)) + .limit(1) + const commit = result[0]?.commits + if (!commit) return Result.error(new NotFoundError('Commit not found')) + + return Result.ok(commit) + } + + async getCommitById(id: number) { + const result = await this.db + .select() + .from(this.scope) + .where(eq(commits.id, id)) + .limit(1) + const commit = result[0]?.commits + if (!commit) return Result.error(new NotFoundError('Commit not found')) + + return Result.ok(commit) + } + + async getCommits() { + return this.db.select().from(this.scope) + } + + async getCommitMergedAt({ + projectId, + uuid, + }: { + projectId: number + uuid: string + }) { + if (uuid === HEAD_COMMIT) { + const result = await this.db + .select({ mergedAt: commits.mergedAt }) + .from(this.scope) + .where( + and(eq(commits.projectId, projectId), isNotNull(commits.mergedAt)), + ) + .orderBy(desc(commits.mergedAt)) + .limit(1) + + if (!result.length) { + return Result.error(new NotFoundError('No head commit found')) + } + const headCommit = result[0]! + return Result.ok(headCommit.mergedAt!) + } + + const result = await this.db + .select() + .from(this.scope) + .where(eq(commits.uuid, uuid)) + const commit = result[0]?.commits + if (!commit) return Result.error(new NotFoundError('Commit not found')) + + return Result.ok(commit.mergedAt) + } +} diff --git a/packages/core/src/repositories/documentVersionsRepository.ts b/packages/core/src/repositories/documentVersionsRepository.ts new file mode 100644 index 000000000..8428141f7 --- /dev/null +++ b/packages/core/src/repositories/documentVersionsRepository.ts @@ -0,0 +1,153 @@ +import { NotFoundError, Result } from '$core/lib' +import { commits, documentVersions, projects } from '$core/schema' +import { and, eq, isNotNull, lte, max } from 'drizzle-orm' + +import { CommitsRepository } from './commitsRepository' +import Repository from './repository' + +export class DocumentVersionsRepository extends Repository { + get scope() { + return this.db + .select() + .from(documentVersions) + .innerJoin(projects, eq(projects.workspaceId, this.workspaceId)) + .innerJoin(commits, eq(commits.projectId, projects.id)) + .where(eq(documentVersions.commitId, commits.id)) + .as('documentVersionsScope') + } + + async getDocumentById(documentId: number) { + const res = await this.db + .select() + .from(this.scope) + .where(eq(documentVersions.id, documentId)) + + // NOTE: I hate this + const document = res[0]?.document_versions + if (!document) return Result.error(new NotFoundError('Document not found')) + + return Result.ok(document) + } + + async getDocumentByPath({ + projectId, + commitUuid, + path, + }: { + projectId: number + commitUuid: string + path: string + }) { + try { + const result = await this.getDocumentsAtCommit({ projectId, commitUuid }) + const documents = result.unwrap() + const document = documents.find((doc) => doc.path === path) + if (!document) { + return Result.error( + new NotFoundError( + `No document with path ${path} at commit ${commitUuid}`, + ), + ) + } + + return Result.ok(document) + } catch (err) { + return Result.error(err as Error) + } + } + + async getDocumentsAtCommit({ + commitUuid, + projectId, + }: { + commitUuid: string + projectId: number + }) { + const commitsScope = new CommitsRepository(this.workspaceId) + const maxMergedAtResult = await commitsScope.getCommitMergedAt({ + uuid: commitUuid, + projectId, + }) + if (maxMergedAtResult.error) return maxMergedAtResult + const maxMergedAt = maxMergedAtResult.unwrap() + + const whereStatement = () => { + const mergedAtNotNull = isNotNull(commits.mergedAt) + if (!maxMergedAt) { + return mergedAtNotNull + } + return and(mergedAtNotNull, lte(commits.mergedAt, maxMergedAt)) + } + + const lastVersionOfEachDocument = this.db + .$with('lastVersionOfDocuments') + .as( + this.db + .select({ + documentUuid: documentVersions.documentUuid, + mergedAt: max(commits.mergedAt).as('maxMergedAt'), + }) + .from(this.scope) + .innerJoin(commits, eq(commits.id, documentVersions.commitId)) + .where(whereStatement()) + .groupBy(documentVersions.documentUuid), + ) + + const documentsAtPreviousMergedCommitsResult = await this.db + .with(lastVersionOfEachDocument) + .select() + .from(this.scope) + .innerJoin( + commits, + and( + eq(commits.id, documentVersions.commitId), + isNotNull(commits.mergedAt), + ), + ) + .innerJoin( + lastVersionOfEachDocument, + and( + eq( + documentVersions.documentUuid, + lastVersionOfEachDocument.documentUuid, + ), + eq(commits.mergedAt, lastVersionOfEachDocument.mergedAt), + ), + ) + + const documentsAtPreviousMergedCommits = + documentsAtPreviousMergedCommitsResult.map( + (d) => d.documentVersionsScope.document_versions, + ) + + if (maxMergedAt) { + // Referenced commit is merged. No additional documents to return. + return Result.ok(documentsAtPreviousMergedCommits) + } + + const commitResult = await commitsScope.getCommitByUuid({ + projectId, + uuid: commitUuid, + }) + if (commitResult.error) return commitResult + + const commit = commitResult.unwrap() + + const documentsAtDraftResult = await this.db + .select() + .from(this.scope) + .innerJoin(commits, eq(commits.id, documentVersions.commitId)) + .where(eq(commits.id, commit.id)) + + const documentsAtDraft = documentsAtDraftResult.map( + (d) => d.documentVersionsScope.document_versions, + ) + const totalDocuments = documentsAtPreviousMergedCommits + .filter((d) => + documentsAtDraft.find((d2) => d2.documentUuid !== d.documentUuid), + ) + .concat(documentsAtDraft) + + return Result.ok(totalDocuments) + } +} diff --git a/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.test.ts b/packages/core/src/repositories/getDocumentsAtCommit.test.ts similarity index 80% rename from packages/core/src/data-access/documentVersions/getDocumentsAtCommit.test.ts rename to packages/core/src/repositories/getDocumentsAtCommit.test.ts index 54100135f..d26cb08a3 100644 --- a/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.test.ts +++ b/packages/core/src/repositories/getDocumentsAtCommit.test.ts @@ -3,20 +3,22 @@ import { mergeCommit } from '$core/services/commits/merge' import useTestDatabase from '$core/tests/useTestDatabase' import { describe, expect, it } from 'vitest' -import { getDocumentsAtCommit } from './getDocumentsAtCommit' +import { DocumentVersionsRepository } from './documentVersionsRepository' useTestDatabase() describe('getDocumentsAtCommit', () => { it('returns the document of the only commit', async (ctx) => { const { project } = await ctx.factories.createProject() + const workspaceId = project.workspaceId const { commit } = await ctx.factories.createDraft({ project }) const { documentVersion: doc } = await ctx.factories.createDocumentVersion({ commit, }) - await mergeCommit({ commitId: commit.id }) + await mergeCommit(commit) - const result = await getDocumentsAtCommit({ + const scope = new DocumentVersionsRepository(workspaceId) + const result = await scope.getDocumentsAtCommit({ commitUuid: commit.uuid, projectId: project.id, }) @@ -28,6 +30,8 @@ describe('getDocumentsAtCommit', () => { it('returns the right document version for each commit', async (ctx) => { const { project } = await ctx.factories.createProject() + const workspaceId = project.workspaceId + const scope = new DocumentVersionsRepository(workspaceId) const { commit: commit1 } = await ctx.factories.createDraft({ project }) const { documentVersion: doc } = await ctx.factories.createDocumentVersion({ @@ -49,12 +53,10 @@ describe('getDocumentsAtCommit', () => { content: 'VERSION 3', }) - // Commit 1 is merged AFTER commit 2 - // Commit 3 is not merged - await mergeCommit({ commitId: commit2.id }) - await mergeCommit({ commitId: commit1.id }) + await mergeCommit(commit2) + await mergeCommit(commit1) - const commit1Result = await getDocumentsAtCommit({ + const commit1Result = await scope.getDocumentsAtCommit({ commitUuid: commit1.uuid, projectId: project.id, }) @@ -62,7 +64,7 @@ describe('getDocumentsAtCommit', () => { expect(commit1Docs.length).toBe(1) expect(commit1Docs[0]!.content).toBe('VERSION 1') - const commit2Result = await getDocumentsAtCommit({ + const commit2Result = await scope.getDocumentsAtCommit({ commitUuid: commit2.uuid, projectId: project.id, }) @@ -70,7 +72,7 @@ describe('getDocumentsAtCommit', () => { expect(commit2Docs.length).toBe(1) expect(commit2Docs[0]!.content).toBe('VERSION 2') - const commit3Result = await getDocumentsAtCommit({ + const commit3Result = await scope.getDocumentsAtCommit({ commitUuid: commit3.uuid, projectId: project.id, }) @@ -78,7 +80,7 @@ describe('getDocumentsAtCommit', () => { expect(commit3Docs.length).toBe(1) expect(commit3Docs[0]!.content).toBe('VERSION 3') - const headResult = await getDocumentsAtCommit({ + const headResult = await scope.getDocumentsAtCommit({ commitUuid: HEAD_COMMIT, projectId: project.id, }) @@ -89,19 +91,21 @@ describe('getDocumentsAtCommit', () => { it('returns documents that were last modified in a previous commit', async (ctx) => { const { project } = await ctx.factories.createProject() + const workspaceId = project.workspaceId + const scope = new DocumentVersionsRepository(workspaceId) const { commit: commit1 } = await ctx.factories.createDraft({ project }) await ctx.factories.createDocumentVersion({ commit: commit1, content: 'Doc 1 commit 1', }) - await mergeCommit({ commitId: commit1.id }) + await mergeCommit(commit1) const { commit: commit2 } = await ctx.factories.createDraft({ project }) const { documentVersion: doc2 } = await ctx.factories.createDocumentVersion( { commit: commit2, content: 'Doc 2 commit 2' }, ) - await mergeCommit({ commitId: commit2.id }) + await mergeCommit(commit2) const { commit: commit3 } = await ctx.factories.createDraft({ project }) await ctx.factories.createDocumentVersion({ @@ -110,7 +114,7 @@ describe('getDocumentsAtCommit', () => { content: 'Doc 2 commit 3 (draft)', }) - const commit1Result = await getDocumentsAtCommit({ + const commit1Result = await scope.getDocumentsAtCommit({ commitUuid: commit1.uuid, projectId: project.id, }) @@ -119,7 +123,7 @@ describe('getDocumentsAtCommit', () => { const commit1DocContents = commit1Docs.map((d) => d.content) expect(commit1DocContents).toContain('Doc 1 commit 1') - const commit2Result = await getDocumentsAtCommit({ + const commit2Result = await scope.getDocumentsAtCommit({ commitUuid: commit2.uuid, projectId: project.id, }) @@ -129,7 +133,7 @@ describe('getDocumentsAtCommit', () => { expect(commit2DocContents).toContain('Doc 1 commit 1') expect(commit2DocContents).toContain('Doc 2 commit 2') - const commit3Result = await getDocumentsAtCommit({ + const commit3Result = await scope.getDocumentsAtCommit({ commitUuid: commit3.uuid, projectId: project.id, }) @@ -139,7 +143,7 @@ describe('getDocumentsAtCommit', () => { expect(commit3DocContents).toContain('Doc 1 commit 1') expect(commit3DocContents).toContain('Doc 2 commit 3 (draft)') - const headResult = await getDocumentsAtCommit({ + const headResult = await scope.getDocumentsAtCommit({ commitUuid: HEAD_COMMIT, projectId: project.id, }) diff --git a/packages/core/src/repositories/index.ts b/packages/core/src/repositories/index.ts new file mode 100644 index 000000000..eaa8ae3dc --- /dev/null +++ b/packages/core/src/repositories/index.ts @@ -0,0 +1,3 @@ +export * from './commitsRepository' +export * from './projectsRepository' +export * from './documentVersionsRepository' diff --git a/packages/core/src/repositories/projectsRepository.ts b/packages/core/src/repositories/projectsRepository.ts new file mode 100644 index 000000000..52196feb9 --- /dev/null +++ b/packages/core/src/repositories/projectsRepository.ts @@ -0,0 +1,39 @@ +import { NotFoundError, Result } from '$core/lib' +import { projects } from '$core/schema' +import { eq } from 'drizzle-orm' + +import Repository from './repository' + +const NOT_FOUND_MSG = 'Project not found' + +export class ProjectsRepository extends Repository { + get scope() { + return this.db + .select() + .from(projects) + .where(eq(projects.workspaceId, this.workspaceId)) + .as('projectsScope') + } + + async getProjectById(id: number) { + const result = await this.db + .select() + .from(this.scope) + .where(eq(projects.id, id)) + const project = result[0] + + if (!project) { + return Result.error(new NotFoundError(NOT_FOUND_MSG)) + } + + return Result.ok(project) + } + + async getFirstProject() { + const result = await this.db.select().from(this.scope).limit(1) + const project = result[0] + if (!project) return Result.error(new NotFoundError(NOT_FOUND_MSG)) + + return Result.ok(project) + } +} diff --git a/packages/core/src/repositories/repository.ts b/packages/core/src/repositories/repository.ts new file mode 100644 index 000000000..6c222a5d8 --- /dev/null +++ b/packages/core/src/repositories/repository.ts @@ -0,0 +1,14 @@ +import { database } from '$core/client' +import { Subquery } from 'drizzle-orm' + +export default abstract class Repository { + protected workspaceId: number + protected db = database + + constructor(workspaceId: number, db = database) { + this.workspaceId = workspaceId + this.db = db + } + + abstract get scope(): Subquery +} diff --git a/packages/core/src/services/commits/merge.ts b/packages/core/src/services/commits/merge.ts index f0e049659..6d71cbd7b 100644 --- a/packages/core/src/services/commits/merge.ts +++ b/packages/core/src/services/commits/merge.ts @@ -5,30 +5,21 @@ import { Result, Transaction, } from '@latitude-data/core' -import { LatitudeError, NotFoundError } from '$core/lib/errors' +import { LatitudeError } from '$core/lib/errors' import { and, eq } from 'drizzle-orm' -export async function mergeCommit( - { commitId }: { commitId: number }, - db = database, -) { +export async function mergeCommit(commit: Commit, db = database) { return Transaction.call(async (tx) => { const mergedAt = new Date() - - const commit = await tx.query.commits.findFirst({ - where: eq(commits.id, commitId), - }) - - if (!commit) return Result.error(new NotFoundError('Commit not found')) - - // Check that there is no other commit with same mergeAt in the same project - const otherCommits = await tx.query.commits.findMany({ - where: and( - eq(commits.projectId, commit.projectId), - eq(commits.mergedAt, mergedAt), - ), - }) - + const otherCommits = await tx + .select() + .from(commits) + .where( + and( + eq(commits.projectId, commit.projectId), + eq(commits.mergedAt, mergedAt), + ), + ) if (otherCommits.length > 0) { return Result.error( new LatitudeError('Commit merge time conflict, try again'), @@ -38,7 +29,7 @@ export async function mergeCommit( const result = await tx .update(commits) .set({ mergedAt }) - .where(eq(commits.id, commitId)) + .where(eq(commits.id, commit.id)) .returning() const updatedCommit = result[0]! diff --git a/packages/core/src/services/documentVersions/create.ts b/packages/core/src/services/documentVersions/create.ts index d2c8a02fe..01b1badf3 100644 --- a/packages/core/src/services/documentVersions/create.ts +++ b/packages/core/src/services/documentVersions/create.ts @@ -1,7 +1,8 @@ import { + CommitsRepository, DocumentVersion, documentVersions, - findCommit, + Project, Result, Transaction, } from '@latitude-data/core' @@ -35,18 +36,22 @@ function createDocument({ export async function createDocumentVersion({ documentUuid, - projectId, + project, path, commitUuid, content, }: { documentUuid?: string - projectId: number + project: Project path: string commitUuid: string content?: string }) { - const resultFindCommit = await findCommit({ uuid: commitUuid, projectId }) + const commitsScope = new CommitsRepository(project.workspaceId) + const resultFindCommit = await commitsScope.getCommitByUuid({ + uuid: commitUuid, + projectId: project.id, + }) if (resultFindCommit.error) return resultFindCommit diff --git a/packages/core/src/services/projects/create.ts b/packages/core/src/services/projects/create.ts index 22dda57c0..284b86eab 100644 --- a/packages/core/src/services/projects/create.ts +++ b/packages/core/src/services/projects/create.ts @@ -8,6 +8,7 @@ import { import { createCommit } from '$core/services/commits/create' import { mergeCommit } from '$core/services/commits/merge' +// TODO: pass a workspace instead of workspaceId export async function createProject( { workspaceId, @@ -22,15 +23,13 @@ export async function createProject( const project = ( await tx.insert(projects).values({ workspaceId, name }).returning() )[0]! - const commit = await createCommit({ + const result = await createCommit({ commit: { projectId: project.id, title: 'Initial version' }, db: tx, }) + if (result.error) return result - if (commit.error) return commit - - const resultMerge = await mergeCommit({ commitId: commit.value.id }, tx) - + const resultMerge = await mergeCommit(result.value) if (resultMerge.error) return resultMerge return Result.ok(project) diff --git a/packages/core/src/tests/factories/documents.ts b/packages/core/src/tests/factories/documents.ts index 04807e68e..e6c623a90 100644 --- a/packages/core/src/tests/factories/documents.ts +++ b/packages/core/src/tests/factories/documents.ts @@ -1,6 +1,8 @@ import { faker } from '@faker-js/faker' -import type { Commit } from '$core/schema' +import { database } from '$core/client' +import { projects, type Commit } from '$core/schema' import { createDocumentVersion as createDocumentVersionFn } from '$core/services/documentVersions/create' +import { eq } from 'drizzle-orm' export type IDocumentVersionData = { commit: Commit @@ -27,8 +29,12 @@ export async function createDocumentVersion( ...documentData, } + const project = await database.query.projects.findFirst({ + where: eq(projects.id, data.commit.projectId), + }) + const result = await createDocumentVersionFn({ - projectId: data.commit.projectId, + project: project!, path: data.path, commitUuid: data.commit.uuid, documentUuid: data.documentUuid, diff --git a/packages/core/src/tests/factories/projects.ts b/packages/core/src/tests/factories/projects.ts index 8026229cf..7834b69e3 100644 --- a/packages/core/src/tests/factories/projects.ts +++ b/packages/core/src/tests/factories/projects.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker' -import { getUser } from '$core/data-access' +import { unsafelyGetUser } from '$core/data-access' import { Workspace, type SafeUser } from '$core/schema' import { createProject as createProjectFn } from '$core/services/projects' @@ -15,7 +15,7 @@ export async function createProject(projectData: Partial = {}) { let workspace: Workspace if ('id' in workspaceData) { - user = (await getUser(workspaceData.creatorId!)) as SafeUser + user = (await unsafelyGetUser(workspaceData.creatorId!)) as SafeUser workspace = workspaceData as Workspace } else { const newWorkspace = await createWorkspace(workspaceData)