diff --git a/apps/web/src/app/(private)/_data-access/index.ts b/apps/web/src/app/(private)/_data-access/index.ts index 5f94fcbe0..c99f6afe1 100644 --- a/apps/web/src/app/(private)/_data-access/index.ts +++ b/apps/web/src/app/(private)/_data-access/index.ts @@ -30,8 +30,7 @@ export const getFirstProjectCached = cache( export const getActiveProjectsCached = cache( async ({ workspaceId }: { workspaceId: number }) => { const projectsScope = new ProjectsRepository(workspaceId) - const result = - await projectsScope.findAllActiveDocumentsWithAgreggatedData() + const result = await projectsScope.findAllActiveWithAgreggatedData() const projects = result.unwrap() return projects diff --git a/apps/web/src/app/(private)/dashboard/_components/ProjectsTable/index.tsx b/apps/web/src/app/(private)/dashboard/_components/ProjectsTable/index.tsx index 425debc27..ec7e8bd23 100644 --- a/apps/web/src/app/(private)/dashboard/_components/ProjectsTable/index.tsx +++ b/apps/web/src/app/(private)/dashboard/_components/ProjectsTable/index.tsx @@ -17,8 +17,7 @@ import { ROUTES } from '$/services/routes' import Link from 'next/link' type ProjectWithAgreggatedData = Project & { - documentCount: number - lastCreatedAtDocument: Date | null + lastEditedAt: Date | null } export function ProjectsTable({ projects, @@ -31,7 +30,6 @@ export function ProjectsTable({ Name - Prompts Edited Created @@ -52,12 +50,7 @@ export function ProjectsTable({ - {project.documentCount || '-'} - - - - - {relativeTime(project.lastCreatedAtDocument)} + {relativeTime(project.lastEditedAt)} diff --git a/packages/core/src/repositories/projectsRepository.test.ts b/packages/core/src/repositories/projectsRepository.test.ts index e1339cd5a..90822adb9 100644 --- a/packages/core/src/repositories/projectsRepository.test.ts +++ b/packages/core/src/repositories/projectsRepository.test.ts @@ -1,110 +1,161 @@ +import { and, eq } from 'drizzle-orm' import { beforeEach, describe, expect, it } from 'vitest' -import { Workspace } from '../browser' +import { ProviderApiKey, User, Workspace } from '../browser' +import { database } from '../client' import { Providers } from '../constants' -import { createProject, createWorkspace, helpers } from '../tests/factories' +import { documentVersions } from '../schema' +import { mergeCommit } from '../services/commits' +import { + createDocumentVersion, + createDraft, + createProject, + createProviderApiKey, + createWorkspace, + destroyDocumentVersion, + helpers, + updateDocumentVersion, +} from '../tests/factories' import { ProjectsRepository } from './projectsRepository' describe('ProjectsRepository', async () => { let repository: ProjectsRepository let workspace: Workspace - - const provider = { type: Providers.OpenAI, name: 'OpenAI' } + let user: User + let provider: ProviderApiKey beforeEach(async () => { - const { workspace: newWorkspace } = await createWorkspace() - workspace = newWorkspace + const { workspace: w, userData: u } = await createWorkspace() + workspace = w + user = u + provider = await createProviderApiKey({ + workspace, + type: Providers.OpenAI, + name: 'OpenAI', + user, + }) repository = new ProjectsRepository(workspace.id) }) - describe('findAllActiveDocumentsWithAgreggatedData', () => { - it('should return active projects with aggregated data', async () => { - // Create test data - const { project: project1 } = await createProject({ + describe('findAllActiveWithAgreggatedData', () => { + it('returns active projects ordered by lastEditedAt and createdAt', async () => { + // When there are no projects + let result = await repository.findAllActiveWithAgreggatedData() + + expect(result.ok).toBe(true) + expect(result.unwrap()).toEqual([]) + + // After creating project0 and project1 + let { project: project0, commit: commit0 } = await createProject({ + name: 'project0', workspace, - providers: [provider], - documents: { - foo: helpers.createPrompt({ provider: provider.name }), - }, + documents: {}, + skipMerge: true, }) - - const { project: project2 } = await createProject({ + let { project: project1, commit: commit1 } = await createProject({ + name: 'project1', workspace, - documents: { - bar: helpers.createPrompt({ provider: provider.name }), - }, + documents: {}, + skipMerge: true, }) - // Execute the method - const result = await repository.findAllActiveDocumentsWithAgreggatedData() + result = await repository.findAllActiveWithAgreggatedData() - // Assert the result expect(result.ok).toBe(true) - const projects = result.unwrap() - - expect(projects).toHaveLength(2) - - const project1Result = projects.find((p) => p.id === project1.id) - expect(project1Result).toBeDefined() - expect(project1Result?.documentCount).toBe(1) - expect(project1Result?.lastCreatedAtDocument).toBeDefined() + expect(result.unwrap()).toEqual([ + { ...project1, lastEditedAt: null }, + { ...project0, lastEditedAt: null }, + ]) - const project2Result = projects.find((p) => p.id === project2.id) - expect(project2Result).toBeDefined() - expect(project2Result?.documentCount).toBe(1) - expect(project2Result?.lastCreatedAtDocument).toBeDefined() - }) - - it('should return projects with zero document count when no documents exist', async () => { - const { project } = await createProject({ + // After adding a document to project1 and modifying it + let { documentVersion: document1 } = await createDocumentVersion({ workspace, - providers: [provider], + user, + commit: commit1, + path: 'document1', + content: helpers.createPrompt({ provider, content: 'content1' }), + }) + document1 = await updateDocumentVersion({ + document: document1, + commit: commit1, + content: helpers.createPrompt({ provider, content: 'newContent1' }), }) - const result = await repository.findAllActiveDocumentsWithAgreggatedData() + result = await repository.findAllActiveWithAgreggatedData() expect(result.ok).toBe(true) - const projects = result.unwrap() + expect(result.unwrap()).toEqual([ + { ...project1, lastEditedAt: document1.updatedAt }, + { ...project0, lastEditedAt: null }, + ]) + + // After publishing project1 and adding a document to project0 + commit1 = await mergeCommit(commit1).then((r) => r.unwrap()) + document1 = await database + .select() + .from(documentVersions) + .where( + and( + eq(documentVersions.documentUuid, document1.documentUuid), + eq(documentVersions.commitId, commit1.id), + ), + ) + .then((d) => d[0]!) + let { documentVersion: document0 } = await createDocumentVersion({ + workspace, + user, + commit: commit0, + path: 'document0', + content: helpers.createPrompt({ provider, content: 'content0' }), + }) - expect(projects).toHaveLength(1) - expect(projects[0]?.id).toBe(project.id) - expect(projects[0]?.documentCount).toBe(0) - expect(projects[0]?.lastCreatedAtDocument).toBeNull() - }) + result = await repository.findAllActiveWithAgreggatedData() - it('should include projects without merged commits', async () => { - await createProject({ + expect(result.ok).toBe(true) + expect(result.unwrap()).toEqual([ + { ...project0, lastEditedAt: document0.updatedAt }, + { ...project1, lastEditedAt: document1.updatedAt }, + ]) + + // After creating project2 and deleting a document from project1 + commit1 = await createDraft({ project: project1, user }).then( + (c) => c.commit, + ) + let { project: project2 } = await createProject({ + name: 'project2', workspace, - providers: [provider], + documents: {}, skipMerge: true, }) + document1 = await destroyDocumentVersion({ + document: document1, + commit: commit1, + }).then((d) => d!) - const result = await repository.findAllActiveDocumentsWithAgreggatedData() + result = await repository.findAllActiveWithAgreggatedData() expect(result.ok).toBe(true) - const projects = result.unwrap() - - expect(projects).toHaveLength(1) - }) + expect(result.unwrap()).toEqual([ + { ...project1, lastEditedAt: document1.updatedAt }, + { ...project2, lastEditedAt: null }, + { ...project0, lastEditedAt: document0.updatedAt }, + ]) - it('should not include deleted projects', async () => { - const { project: deletedProject } = await createProject({ + // After creating project3 and deleting it + await createProject({ + name: 'project3', workspace, deletedAt: new Date(), }) - const { project: activeProject } = await createProject({ - workspace, - }) - - const result = await repository.findAllActiveDocumentsWithAgreggatedData() + result = await repository.findAllActiveWithAgreggatedData() expect(result.ok).toBe(true) - const projects = result.unwrap() - - expect(projects).toHaveLength(1) - expect(projects[0]?.id).toBe(activeProject.id) - expect(projects.find((p) => p.id === deletedProject.id)).toBeUndefined() + expect(result.unwrap()).toEqual([ + { ...project1, lastEditedAt: document1.updatedAt }, + { ...project2, lastEditedAt: null }, + { ...project0, lastEditedAt: document0.updatedAt }, + ]) }) }) }) diff --git a/packages/core/src/repositories/projectsRepository.ts b/packages/core/src/repositories/projectsRepository.ts index d43045f4d..d6b44a641 100644 --- a/packages/core/src/repositories/projectsRepository.ts +++ b/packages/core/src/repositories/projectsRepository.ts @@ -1,13 +1,4 @@ -import { - and, - count, - eq, - getTableColumns, - isNotNull, - isNull, - max, - sql, -} from 'drizzle-orm' +import { and, desc, eq, getTableColumns, isNull, max, sql } from 'drizzle-orm' import { Project } from '../browser' import { NotFoundError, Result } from '../lib' @@ -58,38 +49,17 @@ export class ProjectsRepository extends RepositoryLegacy { return Result.ok(result) } - async findAllActiveDocumentsWithAgreggatedData() { - const lastMergedCommit = this.db.$with('lastMergedCommit').as( - this.db - .select({ - projectId: commits.projectId, - maxVersion: max(commits.version).as('maxVersion'), - }) - .from(commits) - .where(and(isNull(commits.deletedAt), isNotNull(commits.mergedAt))) - .groupBy(commits.projectId), - ) + async findAllActiveWithAgreggatedData() { const aggredatedData = this.db.$with('aggredatedData').as( this.db - .with(lastMergedCommit) .select({ id: this.scope.id, - documentCount: count(documentVersions.id).as('documentCount'), - lastCreatedAtDocument: max(documentVersions.createdAt).as( - 'lastCreatedAtDocument', - ), + lastEditedAt: max(documentVersions.updatedAt).as('lastEditedAt'), }) .from(this.scope) .innerJoin(commits, eq(commits.projectId, this.scope.id)) - .innerJoin( - lastMergedCommit, - and( - eq(lastMergedCommit.projectId, this.scope.id), - eq(commits.version, lastMergedCommit.maxVersion), - ), - ) .innerJoin(documentVersions, eq(documentVersions.commitId, commits.id)) - .where(isNull(this.scope.deletedAt)) + .where(and(isNull(this.scope.deletedAt), isNull(commits.deletedAt))) .groupBy(this.scope.id), ) @@ -97,15 +67,17 @@ export class ProjectsRepository extends RepositoryLegacy { .with(aggredatedData) .select({ ...this.scope._.selectedFields, - documentCount: - sql`CAST(CASE WHEN ${aggredatedData.documentCount} IS NULL THEN 0 ELSE ${aggredatedData.documentCount} END AS INTEGER)`.as( - 'documentCount', - ), - lastCreatedAtDocument: aggredatedData.lastCreatedAtDocument, + lastEditedAt: aggredatedData.lastEditedAt, }) .from(this.scope) .leftJoin(aggredatedData, eq(aggredatedData.id, this.scope.id)) .where(isNull(this.scope.deletedAt)) + .orderBy( + desc( + sql`COALESCE(${aggredatedData.lastEditedAt}, ${this.scope.createdAt})`, + ), + desc(this.scope.id), + ) return Result.ok(result) } diff --git a/packages/core/src/tests/factories/documents.ts b/packages/core/src/tests/factories/documents.ts index e5beb08a5..7d395c498 100644 --- a/packages/core/src/tests/factories/documents.ts +++ b/packages/core/src/tests/factories/documents.ts @@ -1,8 +1,9 @@ import { and, eq } from 'drizzle-orm' -import { User, Workspace, type Commit } from '../../browser' +import { DocumentVersion, User, Workspace, type Commit } from '../../browser' import { database } from '../../client' import { documentVersions } from '../../schema' +import { destroyDocument } from '../../services/documents' import { createNewDocument } from '../../services/documents/create' import { updateDocument } from '../../services/documents/update' @@ -30,6 +31,7 @@ export async function markAsSoftDelete( export async function createDocumentVersion( data: IDocumentVersionData & { workspace: Workspace; user: User }, + tx = database, ) { let result = await createNewDocument({ workspace: data.workspace, @@ -47,7 +49,80 @@ export async function createDocumentVersion( }) } - const documentVersion = result.unwrap() + // Fetch created or updated document from db because createNewDocument and + // updateDocument perform 2 updates but return the state of the first one + const document = ( + await tx + .select() + .from(documentVersions) + .where( + and( + eq(documentVersions.documentUuid, result.unwrap().documentUuid), + eq(documentVersions.commitId, data.commit.id), + ), + ) + )[0]! + + return { documentVersion: document } +} + +export async function updateDocumentVersion( + { + document, + commit, + path, + content, + }: { + document: DocumentVersion + commit: Commit + path?: string + content?: string + }, + tx = database, +) { + await updateDocument({ + commit, + document, + path, + content, + }).then((result) => result.unwrap()) + + // Fetch updated document from db because updateDocument performs + // 2 updates but returns the state of the first one + const updatedDocument = ( + await tx + .select() + .from(documentVersions) + .where( + and( + eq(documentVersions.documentUuid, document.documentUuid), + eq(documentVersions.commitId, commit.id), + ), + ) + )[0]! + + return updatedDocument +} + +export async function destroyDocumentVersion( + { document, commit }: { document: DocumentVersion; commit: Commit }, + tx = database, +) { + await destroyDocument({ document, commit }).then((result) => result.unwrap()) + + // Fetch destroyed document from db because destroyDocument does + // not return it. Note, it can be undefined when hard deleted + const destroyedDocument = ( + await tx + .select() + .from(documentVersions) + .where( + and( + eq(documentVersions.documentUuid, document.documentUuid), + eq(documentVersions.commitId, commit.id), + ), + ) + )[0] - return { documentVersion } + return destroyedDocument } diff --git a/packages/core/src/tests/factories/projects.ts b/packages/core/src/tests/factories/projects.ts index c6fcfc63e..14ab3a04d 100644 --- a/packages/core/src/tests/factories/projects.ts +++ b/packages/core/src/tests/factories/projects.ts @@ -1,4 +1,5 @@ import { faker } from '@faker-js/faker' +import { eq } from 'drizzle-orm' import { DocumentVersion, @@ -7,7 +8,9 @@ import { Workspace, WorkspaceDto, } from '../../browser' +import { database } from '../../client' import { unsafelyGetUser } from '../../data-access' +import { projects } from '../../schema' import { mergeCommit } from '../../services/commits' import { createNewDocument, updateDocument } from '../../services/documents' import { updateProject } from '../../services/projects' @@ -85,6 +88,16 @@ export async function createProject(projectData: Partial = {}) { }) let { project, commit } = result.unwrap() + // Tests run within a transaction and the NOW() PostgreSQL function returns + // the transaction start time. Therefore, all projects would be created + // at the same time, messing with tests. This code patches this. + project = await database + .update(projects) + .set({ createdAt: new Date() }) + .where(eq(projects.id, project.id)) + .returning() + .then((p) => p[0]!) + if (projectData.deletedAt) await updateProject(project, { deletedAt: projectData.deletedAt }).then( (r) => r.unwrap(), @@ -144,7 +157,7 @@ export async function createProject(projectData: Partial = {}) { workspace, providers, documents, - commit: commit, + commit, evaluations, } }