From a3be7fae4990f9ca7becee849c00281073adccb6 Mon Sep 17 00:00:00 2001 From: andresgutgon Date: Thu, 25 Jul 2024 13:52:57 +0200 Subject: [PATCH] Save documents in DB from sidebar and make sidebar resizable --- .../documents/destroyDocumentAction.ts | 35 +++ .../documents/getDocumentsAtCommitAction.ts | 24 ++ .../src/app/(private)/_data-access/index.ts | 18 +- .../_components/DocumentsLayout/index.tsx | 36 +++ .../Sidebar/ClientFilesTree/index.tsx | 9 +- .../_components/Sidebar/index.tsx | 32 +-- .../_components/DocumentEditor/index.tsx | 8 +- .../documents/[documentUuid]/layout.tsx | 43 ---- .../documents/[documentUuid]/page.tsx | 24 +- .../versions/[commitUuid]/documents/page.tsx | 15 ++ .../commits/[commitUuid]/documents/route.ts | 2 +- apps/web/src/stores/documentVersions.ts | 112 ++++++++ .../src/repositories/commitsRepository.ts | 15 ++ .../getDocumentAtCommit.test.ts | 32 +++ .../getDocumentsAtCommit.test.ts | 241 ++++++++++++++++++ .../index.ts} | 93 ++++--- .../repositories/getDocumentsAtCommit.test.ts | 153 ----------- packages/core/src/services/commits/create.ts | 14 +- .../core/src/services/documents/create.ts | 2 +- .../src/services/documents/destroyDocument.ts | 39 +++ .../destroyOrSoftDeleteDocuments.test.ts | 87 +++++++ .../documents/destroyOrSoftDeleteDocuments.ts | 156 ++++++++++++ packages/core/src/services/documents/index.ts | 1 + .../src/services/documents/update.test.ts | 2 +- .../core/src/services/documents/update.ts | 56 ++-- packages/core/src/services/documents/utils.ts | 14 +- packages/core/src/tests/factories/commits.ts | 2 +- .../core/src/tests/factories/documents.ts | 11 +- packages/core/src/tests/factories/projects.ts | 7 +- packages/core/src/tests/setup.ts | 1 + .../src/sections/Document/Editor/index.tsx | 7 +- .../Sidebar/Files/FilesProvider/index.tsx | 6 +- .../sections/Document/Sidebar/Files/index.tsx | 136 ++++++---- .../Sidebar/Files/useTempNodes/index.test.ts | 12 + .../Sidebar/Files/useTempNodes/index.ts | 17 +- .../Document/Sidebar/Files/useTree/index.ts | 6 +- 36 files changed, 1099 insertions(+), 369 deletions(-) create mode 100644 apps/web/src/actions/documents/destroyDocumentAction.ts create mode 100644 apps/web/src/actions/documents/getDocumentsAtCommitAction.ts create mode 100644 apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/DocumentsLayout/index.tsx delete mode 100644 apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/layout.tsx create mode 100644 apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/page.tsx create mode 100644 apps/web/src/stores/documentVersions.ts create mode 100644 packages/core/src/repositories/documentVersionsRepository/getDocumentAtCommit.test.ts create mode 100644 packages/core/src/repositories/documentVersionsRepository/getDocumentsAtCommit.test.ts rename packages/core/src/repositories/{documentVersionsRepository.ts => documentVersionsRepository/index.ts} (87%) delete mode 100644 packages/core/src/repositories/getDocumentsAtCommit.test.ts create mode 100644 packages/core/src/services/documents/destroyDocument.ts create mode 100644 packages/core/src/services/documents/destroyOrSoftDeleteDocuments.test.ts create mode 100644 packages/core/src/services/documents/destroyOrSoftDeleteDocuments.ts diff --git a/apps/web/src/actions/documents/destroyDocumentAction.ts b/apps/web/src/actions/documents/destroyDocumentAction.ts new file mode 100644 index 000000000..2c8608cd0 --- /dev/null +++ b/apps/web/src/actions/documents/destroyDocumentAction.ts @@ -0,0 +1,35 @@ +'use server' + +import { + CommitsRepository, + destroyDocument, + DocumentVersionsRepository, +} from '@latitude-data/core' +import { z } from 'zod' + +import { withProject } from '../procedures' + +export const destroyDocumentAction = withProject + .createServerAction() + .input(z.object({ documentUuid: z.string(), commitId: z.number() }), { + type: 'json', + }) + .handler(async ({ input, ctx }) => { + const commitsScope = new CommitsRepository(ctx.project.workspaceId) + const commit = await commitsScope + .getCommitById(input.commitId) + .then((r) => r.unwrap()) + const docsScope = new DocumentVersionsRepository(ctx.project.workspaceId) + const document = await docsScope + .getDocumentByUuid({ + commit, + documentUuid: input.documentUuid, + }) + .then((r) => r.unwrap()) + const result = await destroyDocument({ + document, + commit, + workspaceId: ctx.project.workspaceId, + }) + return result.unwrap() + }) diff --git a/apps/web/src/actions/documents/getDocumentsAtCommitAction.ts b/apps/web/src/actions/documents/getDocumentsAtCommitAction.ts new file mode 100644 index 000000000..a0c2aea98 --- /dev/null +++ b/apps/web/src/actions/documents/getDocumentsAtCommitAction.ts @@ -0,0 +1,24 @@ +'use server' + +import { + CommitsRepository, + DocumentVersionsRepository, +} from '@latitude-data/core' +import { z } from 'zod' + +import { withProject } from '../procedures' + +export const getDocumentsAtCommitAction = withProject + .createServerAction() + .input(z.object({ commitId: z.number() })) + .handler(async ({ input, ctx }) => { + const commit = await new CommitsRepository(ctx.project.workspaceId) + .getCommitById(input.commitId) + .then((r) => r.unwrap()) + const docsScope = new DocumentVersionsRepository(ctx.project.workspaceId) + const result = await docsScope.getDocumentsAtCommit({ + commit, + }) + + return result.unwrap() + }) diff --git a/apps/web/src/app/(private)/_data-access/index.ts b/apps/web/src/app/(private)/_data-access/index.ts index 8c869466c..2b2683cfb 100644 --- a/apps/web/src/app/(private)/_data-access/index.ts +++ b/apps/web/src/app/(private)/_data-access/index.ts @@ -4,11 +4,11 @@ import { Commit, CommitsRepository, DocumentVersionsRepository, - findWorkspaceFromCommit, NotFoundError, Project, ProjectsRepository, } from '@latitude-data/core' +import { getCurrentUser } from '$/services/auth/getCurrentUser' export const getFirstProject = cache( async ({ workspaceId }: { workspaceId: number }) => { @@ -54,9 +54,17 @@ export const getDocumentByUuid = cache( documentUuid: string commit: Commit }) => { - const workspace = await findWorkspaceFromCommit(commit) - const scope = new DocumentVersionsRepository(workspace!.id) + const { workspace } = await getCurrentUser() + const scope = new DocumentVersionsRepository(workspace.id) const result = await scope.getDocumentAtCommit({ documentUuid, commit }) + if (result.error) { + const error = result.error + if (error instanceof NotFoundError) { + return null + } + + throw error + } return result.unwrap() }, @@ -64,10 +72,10 @@ export const getDocumentByUuid = cache( export const getDocumentByPath = cache( async ({ commit, path }: { commit: Commit; path: string }) => { - const workspace = await findWorkspaceFromCommit(commit) + const { workspace } = await getCurrentUser() const docsScope = new DocumentVersionsRepository(workspace!.id) const documents = await docsScope - .getDocumentsAtCommit(commit) + .getDocumentsAtCommit({ commit }) .then((r) => r.unwrap()) const document = documents.find((d) => d.path === path) diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/DocumentsLayout/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/DocumentsLayout/index.tsx new file mode 100644 index 000000000..8f741308c --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/DocumentsLayout/index.tsx @@ -0,0 +1,36 @@ +import { ReactNode } from 'react' + +import { DocumentVersion } from '@latitude-data/core' +import { DocumentDetailWrapper } from '@latitude-data/web-ui' +import { findCommit, findProject } from '$/app/(private)/_data-access' +import { getCurrentUser } from '$/services/auth/getCurrentUser' + +import Sidebar from '../Sidebar' + +export default async function DocumentsLayout({ + children, + document, + commitUuid, + projectId, +}: { + children: ReactNode + document?: DocumentVersion + projectId: number + commitUuid: string +}) { + const session = await getCurrentUser() + const project = await findProject({ + projectId, + workspaceId: session.workspace.id, + }) + const commit = await findCommit({ + project, + uuid: commitUuid, + }) + return ( + + + {children} + + ) +} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ClientFilesTree/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ClientFilesTree/index.tsx index aa01106f1..f2fea97d4 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ClientFilesTree/index.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ClientFilesTree/index.tsx @@ -10,15 +10,15 @@ import { type SidebarDocument, } from '@latitude-data/web-ui' import { ROUTES } from '$/services/routes' +import useDocumentVersions from '$/stores/documentVersions' import { useRouter } from 'next/navigation' export default function ClientFilesTree({ - documents, + documents: serverDocuments, documentPath, }: { documents: SidebarDocument[] documentPath: string | undefined - documentUuid: string | undefined }) { const router = useRouter() const { commit, isHead } = useCurrentCommit() @@ -32,12 +32,17 @@ export default function ClientFilesTree({ .documents.detail({ uuid: documentUuid }).root, ) }, []) + const { createFile, destroyFile, documents } = useDocumentVersions({ + fallbackData: serverDocuments, + }) return ( ) } diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/index.tsx index 321e171d6..c0ec9ef1f 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/index.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/index.tsx @@ -1,35 +1,25 @@ -import { Suspense } from 'react' - -import { - Commit, - DocumentVersionsRepository, - findWorkspaceFromCommit, -} from '@latitude-data/core' +import { Commit, DocumentVersionsRepository } from '@latitude-data/core' import { DocumentSidebar } from '@latitude-data/web-ui' +import { getCurrentUser } from '$/services/auth/getCurrentUser' import ClientFilesTree from './ClientFilesTree' export default async function Sidebar({ commit, - documentUuid, documentPath, }: { commit: Commit documentPath?: string - documentUuid?: string }) { - const workspace = await findWorkspaceFromCommit(commit) - const docsScope = new DocumentVersionsRepository(workspace!.id) - const documents = await docsScope.getDocumentsAtCommit(commit) + const { workspace } = await getCurrentUser() + const docsScope = new DocumentVersionsRepository(workspace.id) + const documents = await docsScope.getDocumentsAtCommit({ commit }) return ( - Loading...}> - - - - + + + ) } diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/index.tsx index bfb82eb4c..5aaa9ecd4 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/index.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/_components/DocumentEditor/index.tsx @@ -3,7 +3,11 @@ import { Suspense, useCallback, useRef } from 'react' import { Commit, DocumentVersion } from '@latitude-data/core' -import { DocumentEditor, useToast } from '@latitude-data/web-ui' +import { + DocumentEditor, + DocumentTextEditorFallback, + useToast, +} from '@latitude-data/web-ui' import { getDocumentContentByPathAction } from '$/actions/documents/getContentByPath' import { updateDocumentContentAction } from '$/actions/documents/updateContent' import { useServerAction } from 'zsa-react' @@ -69,7 +73,7 @@ export default function ClientDocumentEditor({ ) return ( - Loading...}> + }> - - {children} - - ) -} diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/page.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/page.tsx index b193ed22d..2904e1044 100644 --- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/page.tsx +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/page.tsx @@ -4,7 +4,9 @@ import { getDocumentByUuid, } from '$/app/(private)/_data-access' import { getCurrentUser } from '$/services/auth/getCurrentUser' +import { notFound } from 'next/navigation' +import DocumentsLayout from '../../_components/DocumentsLayout' import ClientDocumentEditor from './_components/DocumentEditor' export default async function DocumentPage({ @@ -13,17 +15,27 @@ export default async function DocumentPage({ params: { projectId: string; commitUuid: string; documentUuid: string } }) { const session = await getCurrentUser() + const projectId = Number(params.projectId) + const commintUuid = params.commitUuid const project = await findProject({ - projectId: Number(params.projectId), + projectId, workspaceId: session.workspace.id, }) - const commit = await findCommit({ - project, - uuid: params.commitUuid, - }) + const commit = await findCommit({ project, uuid: commintUuid }) const document = await getDocumentByUuid({ documentUuid: params.documentUuid, commit, }) - return + + if (!document) return notFound() + + return ( + + + + ) } diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/page.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/page.tsx new file mode 100644 index 000000000..7e14cf9f3 --- /dev/null +++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/page.tsx @@ -0,0 +1,15 @@ +import DocumentsLayout from '../_components/DocumentsLayout' + +export default async function DocumentsPage({ + params, +}: { + params: { projectId: string; commitUuid: string; documentUuid: string } +}) { + const projectId = Number(params.projectId) + const commintUuid = params.commitUuid + return ( + +
List of documents
+
+ ) +} 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 580b1036b..a939270a9 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 @@ -23,7 +23,7 @@ export async function GET( const commit = await commitsScope .getCommitByUuid({ uuid: commitUuid, project }) .then((r) => r.unwrap()) - const documents = await scope.getDocumentsAtCommit(commit) + const documents = await scope.getDocumentsAtCommit({ commit }) return NextResponse.json(documents.unwrap()) } catch (err: unknown) { diff --git a/apps/web/src/stores/documentVersions.ts b/apps/web/src/stores/documentVersions.ts new file mode 100644 index 000000000..7c4f322ad --- /dev/null +++ b/apps/web/src/stores/documentVersions.ts @@ -0,0 +1,112 @@ +'use client' + +import { useCallback } from 'react' + +import { DocumentVersion } from '@latitude-data/core' +import { + useCurrentCommit, + useCurrentProject, + useToast, +} from '@latitude-data/web-ui' +import { createDocumentVersionAction } from '$/actions/documents/create' +import { destroyDocumentAction } from '$/actions/documents/destroyDocumentAction' +import { getDocumentsAtCommitAction } from '$/actions/documents/getDocumentsAtCommitAction' +import { ROUTES } from '$/services/routes' +import { useRouter } from 'next/navigation' +import useSWR, { SWRConfiguration } from 'swr' +import { useServerAction } from 'zsa-react' + +export default function useDocumentVersions(opts?: SWRConfiguration) { + const { toast } = useToast() + const router = useRouter() + const { project } = useCurrentProject() + const { commit } = useCurrentCommit() + const { mutate, data, ...rest } = useSWR( + ['documentVersions', project.id, commit.id], + async () => { + const [fetchedDocuments, errorFetchingDocuments] = + await getDocumentsAtCommitAction({ + projectId: project.id, + commitId: commit.id, + }) + + if (errorFetchingDocuments) { + toast({ + title: 'Creating file failed', + description: + errorFetchingDocuments.formErrors?.[0] || + errorFetchingDocuments.message, + variant: 'destructive', + }) + return [] + } + + return fetchedDocuments + }, + opts, + ) + const { execute: executeCreateDocument } = useServerAction( + createDocumentVersionAction, + ) + const { execute: executeDestroyDocument } = useServerAction( + destroyDocumentAction, + ) + const createFile = useCallback( + async ({ path }: { path: string }) => { + const [document, error] = await executeCreateDocument({ + path, + projectId: project.id, + commitUuid: commit.uuid, + }) + + if (error) { + toast({ + title: 'Creating document failed', + description: error.formErrors?.[0] || error.message, + variant: 'destructive', + }) + } else if (document) { + const prevDocuments = data || [] + + if (document) { + mutate([...prevDocuments, document]) + router.push( + ROUTES.projects + .detail({ id: project.id }) + .commits.detail({ uuid: commit.uuid }) + .documents.detail({ uuid: document.documentUuid }).root, + ) + } + } + }, + [executeCreateDocument, mutate, data], + ) + + const destroyFile = useCallback( + async (documentUuid: string) => { + const [_, error] = await executeDestroyDocument({ + documentUuid, + projectId: project.id, + commitId: commit.id, + }) + if (error) { + toast({ + title: 'Deleting document failed', + description: error.formErrors?.[0] || error.message, + variant: 'destructive', + }) + } else { + const prevDocuments = data || [] + mutate(prevDocuments.filter((d) => d.documentUuid !== documentUuid)) + router.push( + ROUTES.projects + .detail({ id: project.id }) + .commits.detail({ uuid: commit.uuid }).documents.root, + ) + } + }, + [executeDestroyDocument, mutate, data], + ) + + return { ...rest, documents: data ?? [], createFile, destroyFile, mutate } +} diff --git a/packages/core/src/repositories/commitsRepository.ts b/packages/core/src/repositories/commitsRepository.ts index 3796dd9e1..6ba2d29dd 100644 --- a/packages/core/src/repositories/commitsRepository.ts +++ b/packages/core/src/repositories/commitsRepository.ts @@ -77,6 +77,21 @@ export class CommitsRepository extends Repository { return this.db.select().from(this.scope) } + async getFirstCommitForProject(project: Project) { + const result = await this.db + .select() + .from(this.scope) + .where(eq(this.scope.projectId, project.id)) + .orderBy(this.scope.createdAt) + .limit(1) + + if (result.length < 1) { + return Result.error(new NotFoundError('No commits found')) + } + + return Result.ok(result[0]!) + } + async getCommitMergedAt({ project, uuid, diff --git a/packages/core/src/repositories/documentVersionsRepository/getDocumentAtCommit.test.ts b/packages/core/src/repositories/documentVersionsRepository/getDocumentAtCommit.test.ts new file mode 100644 index 000000000..0e676184a --- /dev/null +++ b/packages/core/src/repositories/documentVersionsRepository/getDocumentAtCommit.test.ts @@ -0,0 +1,32 @@ +import { omit } from 'lodash-es' + +import { mergeCommit } from '$core/services' +import * as factories from '$core/tests/factories' +import { describe, expect, it } from 'vitest' + +import { DocumentVersionsRepository } from './index' + +describe('getDocumentAtCommit', () => { + it('return doc from merged commit', async () => { + const { project } = await factories.createProject() + const { commit } = await factories.createDraft({ project }) + const { documentVersion: doc } = await factories.createDocumentVersion({ + commit: commit, + content: 'VERSION_1', + }) + const mergedCommit = await mergeCommit(commit).then((r) => r.unwrap()) + const documentUuid = doc.documentUuid + + const documentsScope = new DocumentVersionsRepository(project.workspaceId) + const result = await documentsScope.getDocumentAtCommit({ + commit: mergedCommit, + documentUuid, + }) + const document = result.unwrap() + + expect(omit(document, 'id', 'updatedAt')).toEqual({ + ...omit(doc, 'id', 'updatedAt'), + resolvedContent: 'VERSION_1', + }) + }) +}) diff --git a/packages/core/src/repositories/documentVersionsRepository/getDocumentsAtCommit.test.ts b/packages/core/src/repositories/documentVersionsRepository/getDocumentsAtCommit.test.ts new file mode 100644 index 000000000..0b1750277 --- /dev/null +++ b/packages/core/src/repositories/documentVersionsRepository/getDocumentsAtCommit.test.ts @@ -0,0 +1,241 @@ +import { HEAD_COMMIT } from '$core/constants' +import { Commit, DocumentVersion, Project } from '$core/schema' +import { mergeCommit, updateDocument } from '$core/services' +import * as factories from '$core/tests/factories' +import { beforeAll, describe, expect, it } from 'vitest' + +import { CommitsRepository } from '../commitsRepository' +import { DocumentVersionsRepository } from './index' + +let documentsByContent: Record< + string, + { + project: Project + commit: Commit + document: DocumentVersion + documentsScope: DocumentVersionsRepository + } +> = {} + +describe('getDocumentsAtCommit', () => { + it('returns the document of the only commit', async (ctx) => { + const { + project, + commit, + documents: allDocs, + } = await ctx.factories.createProject({ + documents: { doc1: 'Doc 1' }, + }) + + const documentsScope = new DocumentVersionsRepository(project.workspaceId) + const result = await documentsScope.getDocumentsAtCommit({ commit }) + const documents = result.unwrap() + + expect(documents.length).toBe(1) + expect(documents[0]!.documentUuid).toBe(allDocs[0]!.documentUuid) + }) + + it('get docs from HEAD', async (ctx) => { + const { project, documents } = await ctx.factories.createProject({ + documents: { doc1: 'Doc 1', doc2: 'Doc 2' }, + }) + const documentsScope = new DocumentVersionsRepository(project.workspaceId) + const { commit: draft } = await factories.createDraft({ project }) + await factories.markAsSoftDelete( + documents.find((d) => d.path === 'doc2')!.documentUuid, + ) + const filteredDocs = await documentsScope + .getDocumentsAtCommit({ commit: draft }) + .then((r) => r.unwrap()) + const contents = filteredDocs.map((d) => d.content) + expect(contents).toEqual(['Doc 1']) + }) + + describe('documents for each commit', () => { + beforeAll(async () => { + const { project } = await factories.createProject() + const documentsScope = new DocumentVersionsRepository(project.workspaceId) + const { commit: commit1 } = await factories.createDraft({ project }) + const { commit: commit2 } = await factories.createDraft({ project }) + const { commit: commit3 } = await factories.createDraft({ project }) + + // Initial document + const { documentVersion: doc1 } = await factories.createDocumentVersion({ + commit: commit1, + content: 'VERSION_1', + }) + await mergeCommit(commit1).then((r) => r.unwrap()) + + // Version 2 is merged + const doc2 = await updateDocument({ + commit: commit2, + document: doc1!, + content: 'VERSION_2', + }).then((r) => r.unwrap()) + await mergeCommit(commit2).then((r) => r.unwrap()) + + // A new draft is created + const doc3 = await updateDocument({ + commit: commit3, + document: doc1!, + content: 'VERSION_3_draft', + }).then((r) => r.unwrap()) + + documentsByContent = { + VERSION_1: { project, document: doc1, commit: commit1, documentsScope }, + VERSION_2: { project, document: doc2, commit: commit2, documentsScope }, + VERSION_3_draft: { + project, + document: doc3, + commit: commit3, + documentsScope, + }, + } + }) + + it('get docs from version 1', async () => { + const { project, commit } = documentsByContent.VERSION_1! + const documentsScope = new DocumentVersionsRepository(project.workspaceId) + const documents = await documentsScope + .getDocumentsAtCommit({ commit }) + .then((r) => r.unwrap()) + + expect(documents.length).toBe(1) + expect(documents[0]!.content).toBe('VERSION_1') + }) + + it('get docs from version 2', async () => { + const { project, commit } = documentsByContent.VERSION_2! + const documentsScope = new DocumentVersionsRepository(project.workspaceId) + const documents = await documentsScope + .getDocumentsAtCommit({ commit }) + .then((r) => r.unwrap()) + + expect(documents.length).toBe(1) + expect(documents[0]!.content).toBe('VERSION_2') + }) + + it('get docs from version 3', async () => { + const { project, commit } = documentsByContent.VERSION_3_draft! + const documentsScope = new DocumentVersionsRepository(project.workspaceId) + const documents = await documentsScope + .getDocumentsAtCommit({ commit }) + .then((r) => r.unwrap()) + + expect(documents.length).toBe(1) + expect(documents[0]!.content).toBe('VERSION_3_draft') + }) + + it('get docs from HEAD', async () => { + const { project } = documentsByContent.VERSION_1! + const commitsScope = new CommitsRepository(project.workspaceId) + const documentsScope = new DocumentVersionsRepository(project.workspaceId) + const commit = await commitsScope + .getCommitByUuid({ + project, + uuid: HEAD_COMMIT, + }) + .then((r) => r.unwrap()) + const documents = await documentsScope + .getDocumentsAtCommit({ commit }) + .then((r) => r.unwrap()) + + expect(documents.length).toBe(1) + expect(documents[0]!.content).toBe('VERSION_2') + }) + }) + + describe('documents from previous commits', () => { + beforeAll(async () => { + const { project } = await factories.createProject() + const documentsScope = new DocumentVersionsRepository(project.workspaceId) + + // Doc 1 + const { commit: commit1 } = await factories.createDraft({ project }) + const { documentVersion: doc1 } = await factories.createDocumentVersion({ + commit: commit1, + content: 'Doc_1_commit_1', + }) + const mergedCommit1 = await mergeCommit(commit1).then((r) => r.unwrap()) + + // Doc 2 + const { commit: commit2 } = await factories.createDraft({ project }) + const { documentVersion: doc2 } = await factories.createDocumentVersion({ + commit: commit2, + content: 'Doc_2_commit_2', + }) + const mergedCommit2 = await mergeCommit(commit2).then((r) => r.unwrap()) + + // Doc 3 + const { commit: commit3 } = await factories.createDraft({ project }) + const doc3 = await updateDocument({ + commit: commit3, + document: doc2, + content: 'Doc_2_commit_3_draft', + }).then((r) => r.unwrap()) + + documentsByContent = { + commit1: { + project, + document: doc1, + commit: mergedCommit1, + documentsScope, + }, + commit2: { + project, + document: doc2, + commit: mergedCommit2, + documentsScope, + }, + commit3: { project, document: doc3, commit: commit3, documentsScope }, + } + }) + + it('get docs from commit 1', async () => { + const { commit, documentsScope } = documentsByContent.commit1! + const documents = await documentsScope + .getDocumentsAtCommit({ commit }) + .then((r) => r.unwrap()) + + const contents = documents.map((d) => d.content) + expect(contents).toEqual(['Doc_1_commit_1']) + }) + + it('get docs from commit 2', async () => { + const { documentsScope, commit } = documentsByContent.commit2! + const documents = await documentsScope + .getDocumentsAtCommit({ commit }) + .then((r) => r.unwrap()) + + const contents = documents.map((d) => d.content).sort() + expect(contents).toEqual(['Doc_1_commit_1', 'Doc_2_commit_2']) + }) + + it('get docs from commit 3', async () => { + const { documentsScope, commit } = documentsByContent.commit3! + const documents = await documentsScope + .getDocumentsAtCommit({ commit }) + .then((r) => r.unwrap()) + + const contents = documents.map((d) => d.content).sort() + expect(contents).toEqual(['Doc_1_commit_1', 'Doc_2_commit_3_draft']) + }) + + it('get docs from HEAD', async () => { + const { project, documentsScope } = documentsByContent.commit1! + const commitsScope = new CommitsRepository(project.workspaceId) + const commit = await commitsScope + .getCommitByUuid({ + project: project, + uuid: HEAD_COMMIT, + }) + .then((r) => r.unwrap()) + const documents = await documentsScope + .getDocumentsAtCommit({ commit }) + .then((r) => r.unwrap()) + + const contents = documents.map((d) => d.content).sort() + expect(contents).toEqual(['Doc_1_commit_1', 'Doc_2_commit_2']) + }) + }) +}) diff --git a/packages/core/src/repositories/documentVersionsRepository.ts b/packages/core/src/repositories/documentVersionsRepository/index.ts similarity index 87% rename from packages/core/src/repositories/documentVersionsRepository.ts rename to packages/core/src/repositories/documentVersionsRepository/index.ts index c6a94ec52..b6ceccfdc 100644 --- a/packages/core/src/repositories/documentVersionsRepository.ts +++ b/packages/core/src/repositories/documentVersionsRepository/index.ts @@ -8,7 +8,19 @@ import { } from '$core/schema' import { and, eq, getTableColumns, isNotNull, lte, max } from 'drizzle-orm' -import Repository from './repository' +import Repository from '../repository' + +function mergeDocuments( + ...documentsArr: DocumentVersion[][] +): DocumentVersion[] { + return documentsArr.reduce((acc, documents) => { + return acc + .filter((d) => { + return !documents.find((d2) => d2.documentUuid === d.documentUuid) + }) + .concat(documents) + }, []) +} export type GetDocumentAtCommitProps = { commit: Commit @@ -65,7 +77,7 @@ export class DocumentVersionsRepository extends Repository { async getDocumentByPath({ commit, path }: { commit: Commit; path: string }) { try { - const result = await this.getDocumentsAtCommit(commit) + const result = await this.getDocumentsAtCommit({ commit }) const documents = result.unwrap() const document = documents.find((doc) => doc.path === path) if (!document) { @@ -82,30 +94,15 @@ export class DocumentVersionsRepository extends Repository { } } - async getDocumentsAtCommit(commit: Commit) { - const documentsFromMergedCommits = - await this.fetchDocumentsFromMergedCommits({ - projectId: commit.projectId, - maxMergedAt: commit.mergedAt, - }) + /** + * NOTE: By default we don't include deleted documents + */ + async getDocumentsAtCommit({ commit }: { commit: Commit }) { + const result = await this.getAllDocumentsAtCommit({ commit }) - if (commit.mergedAt !== null) { - // Referenced commit is merged. No additional documents to return. - return Result.ok(documentsFromMergedCommits) - } - - const documentsFromDraft = await this.db - .select(getTableColumns(documentVersions)) - .from(documentVersions) - .innerJoin(commits, eq(commits.id, documentVersions.commitId)) - .where(eq(commits.id, commit.id)) - - const totalDocuments = mergeDocuments( - documentsFromMergedCommits, - documentsFromDraft, - ) + if (result.error) return result - return Result.ok(totalDocuments) + return Result.ok(result.value.filter((d) => d.deletedAt === null)) } async getDocumentAtCommit({ @@ -117,20 +114,22 @@ export class DocumentVersionsRepository extends Repository { .from(this.scope) .where( and( - eq(documentVersions.commitId, commit.id), - eq(documentVersions.documentUuid, documentUuid), + eq(this.scope.commitId, commit.id), + eq(this.scope.documentUuid, documentUuid), ), ) .limit(1) .then((docs) => docs[0]) + if (documentInCommit !== undefined) return Result.ok(documentInCommit) - const documentsAtCommit = await this.getDocumentsAtCommit(commit) + const documentsAtCommit = await this.getDocumentsAtCommit({ commit }) if (documentsAtCommit.error) return Result.error(documentsAtCommit.error) const document = documentsAtCommit.value.find( (d) => d.documentUuid === documentUuid, ) + if (!document) return Result.error(new NotFoundError('Document not found')) return Result.ok(document) @@ -145,6 +144,32 @@ export class DocumentVersionsRepository extends Repository { return Result.ok(changedDocuments) } + private async getAllDocumentsAtCommit({ commit }: { commit: Commit }) { + const documentsFromMergedCommits = + await this.fetchDocumentsFromMergedCommits({ + projectId: commit.projectId, + maxMergedAt: commit.mergedAt, + }) + + if (commit.mergedAt !== null) { + // Referenced commit is merged. No additional documents to return. + return Result.ok(documentsFromMergedCommits) + } + + const documentsFromDraft = await this.db + .select(getTableColumns(documentVersions)) + .from(documentVersions) + .innerJoin(commits, eq(commits.id, documentVersions.commitId)) + .where(eq(commits.id, commit.id)) + + const totalDocuments = mergeDocuments( + documentsFromMergedCommits, + documentsFromDraft, + ) + + return Result.ok(totalDocuments) + } + private async fetchDocumentsFromMergedCommits({ projectId, maxMergedAt, @@ -166,6 +191,7 @@ export class DocumentVersionsRepository extends Repository { documentUuid: documentVersions.documentUuid, mergedAt: max(commits.mergedAt).as('maxMergedAt'), }) + // FIXME: This is not using the scope .from(documentVersions) .innerJoin(commits, eq(commits.id, documentVersions.commitId)) .where(and(filterByMaxMergedAt(), eq(commits.projectId, projectId))) @@ -175,6 +201,7 @@ export class DocumentVersionsRepository extends Repository { const documentsFromMergedCommits = await this.db .with(lastVersionOfEachDocument) .select(getTableColumns(documentVersions)) + // FIXME: This is not using the scope .from(documentVersions) .innerJoin( commits, @@ -197,15 +224,3 @@ export class DocumentVersionsRepository extends Repository { return documentsFromMergedCommits } } - -function mergeDocuments( - ...documentsArr: DocumentVersion[][] -): DocumentVersion[] { - return documentsArr.reduce((acc, documents) => { - return acc - .filter((d) => { - return !documents.find((d2) => d2.documentUuid === d.documentUuid) - }) - .concat(documents) - }, []) -} diff --git a/packages/core/src/repositories/getDocumentsAtCommit.test.ts b/packages/core/src/repositories/getDocumentsAtCommit.test.ts deleted file mode 100644 index a95c50875..000000000 --- a/packages/core/src/repositories/getDocumentsAtCommit.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { HEAD_COMMIT } from '$core/constants' -import { updateDocument } from '$core/services' -import { mergeCommit } from '$core/services/commits/merge' -import { describe, expect, it } from 'vitest' - -import { CommitsRepository } from './commitsRepository' -import { DocumentVersionsRepository } from './documentVersionsRepository' - -describe('getDocumentsAtCommit', () => { - it('returns the document of the only commit', async (ctx) => { - const { project } = await ctx.factories.createProject() - const workspaceId = project.workspaceId - let { commit } = await ctx.factories.createDraft({ project }) - const { documentVersion: doc } = await ctx.factories.createDocumentVersion({ - commit, - }) - - commit = await mergeCommit(commit).then((r) => r.unwrap()) - - const scope = new DocumentVersionsRepository(workspaceId) - const result = await scope.getDocumentsAtCommit(commit) - const documents = result.unwrap() - - expect(documents.length).toBe(1) - expect(documents[0]!.documentUuid).toBe(doc.documentUuid) - }) - - 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) - - let { commit: commit1 } = await ctx.factories.createDraft({ project }) - const { documentVersion: doc } = await ctx.factories.createDocumentVersion({ - commit: commit1, - content: 'VERSION 1', - }) - commit1 = await mergeCommit(commit1).then((r) => r.unwrap()) - - const { commit: commit2 } = await ctx.factories.createDraft({ project }) - await updateDocument({ - commit: commit2, - document: doc, - content: 'VERSION 2', - }).then((r) => r.unwrap()) - - const { commit: commit3 } = await ctx.factories.createDraft({ project }) - await updateDocument({ - commit: commit3, - document: doc, - content: 'VERSION 3 (draft)', - }).then((r) => r.unwrap()) - - await mergeCommit(commit2).then((r) => r.unwrap()) - - const commit1Docs = await scope - .getDocumentsAtCommit(commit1) - .then((r) => r.unwrap()) - expect(commit1Docs.length).toBe(1) - expect(commit1Docs[0]!.content).toBe('VERSION 1') - - const commit2Docs = await scope - .getDocumentsAtCommit(commit2) - .then((r) => r.unwrap()) - expect(commit2Docs.length).toBe(1) - expect(commit2Docs[0]!.content).toBe('VERSION 2') - - const commit3Docs = await scope - .getDocumentsAtCommit(commit3) - .then((r) => r.unwrap()) - expect(commit3Docs.length).toBe(1) - expect(commit3Docs[0]!.content).toBe('VERSION 3 (draft)') - - const commitsScope = new CommitsRepository(workspaceId) - const headCommit = await commitsScope - .getCommitByUuid({ - project, - uuid: HEAD_COMMIT, - }) - .then((r) => r.unwrap()) - const headDocs = await scope - .getDocumentsAtCommit(headCommit) - .then((r) => r.unwrap()) - expect(headDocs.length).toBe(1) - expect(headDocs[0]!.content).toBe('VERSION 2') - }) - - 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) - - let { commit: commit1 } = await ctx.factories.createDraft({ project }) - await ctx.factories.createDocumentVersion({ - commit: commit1, - content: 'Doc 1 commit 1', - }) - - commit1 = await mergeCommit(commit1).then((r) => r.unwrap()) - - let { commit: commit2 } = await ctx.factories.createDraft({ project }) - const { documentVersion: doc2 } = await ctx.factories.createDocumentVersion( - { commit: commit2, content: 'Doc 2 commit 2' }, - ) - - commit2 = await mergeCommit(commit2).then((r) => r.unwrap()) - - const { commit: commit3 } = await ctx.factories.createDraft({ project }) - await updateDocument({ - commit: commit3, - document: doc2, - content: 'Doc 2 commit 3 (draft)', - }).then((r) => r.unwrap()) - - const commit1Docs = await scope - .getDocumentsAtCommit(commit1) - .then((r) => r.unwrap()) - expect(commit1Docs.length).toBe(1) - const commit1DocContents = commit1Docs.map((d) => d.content) - expect(commit1DocContents).toContain('Doc 1 commit 1') - - const commit2Docs = await scope - .getDocumentsAtCommit(commit2) - .then((r) => r.unwrap()) - expect(commit2Docs.length).toBe(2) - const commit2DocContents = commit2Docs.map((d) => d.content) - expect(commit2DocContents).toContain('Doc 1 commit 1') - expect(commit2DocContents).toContain('Doc 2 commit 2') - - const commit3Docs = await scope - .getDocumentsAtCommit(commit3) - .then((r) => r.unwrap()) - expect(commit3Docs.length).toBe(2) - const commit3DocContents = commit3Docs.map((d) => d.content) - expect(commit3DocContents).toContain('Doc 1 commit 1') - expect(commit3DocContents).toContain('Doc 2 commit 3 (draft)') - - const commitsScope = new CommitsRepository(workspaceId) - const headCommit = await commitsScope - .getCommitByUuid({ - project, - uuid: HEAD_COMMIT, - }) - .then((r) => r.unwrap()) - const headDocs = await scope - .getDocumentsAtCommit(headCommit) - .then((r) => r.unwrap()) - expect(headDocs.length).toBe(2) - const headDocContents = headDocs.map((d) => d.content) - expect(headDocContents).toContain('Doc 1 commit 1') - expect(headDocContents).toContain('Doc 2 commit 2') - }) -}) diff --git a/packages/core/src/services/commits/create.ts b/packages/core/src/services/commits/create.ts index a141f062a..fda1a24e7 100644 --- a/packages/core/src/services/commits/create.ts +++ b/packages/core/src/services/commits/create.ts @@ -8,19 +8,23 @@ import { } from '@latitude-data/core' export async function createCommit({ - commit, + commit: { projectId, title, mergedAt }, db = database, }: { - commit: Omit, 'id'> + commit: { + projectId: number + title?: string + mergedAt?: Date + } db?: Database }) { return Transaction.call(async (tx) => { const result = await tx .insert(commits) .values({ - projectId: commit.projectId!, - title: commit.title, - mergedAt: commit.mergedAt, + projectId, + title, + mergedAt, }) .returning() const createdCommit = result[0] diff --git a/packages/core/src/services/documents/create.ts b/packages/core/src/services/documents/create.ts index a0e6941cc..2e8775f24 100644 --- a/packages/core/src/services/documents/create.ts +++ b/packages/core/src/services/documents/create.ts @@ -23,7 +23,7 @@ export async function createNewDocument({ const docsScope = new DocumentVersionsRepository(workspace!.id, tx) const currentDocs = await docsScope - .getDocumentsAtCommit(commit) + .getDocumentsAtCommit({ commit }) .then((r) => r.unwrap()) if (currentDocs.find((d) => d.path === path)) { return Result.error( diff --git a/packages/core/src/services/documents/destroyDocument.ts b/packages/core/src/services/documents/destroyDocument.ts new file mode 100644 index 000000000..f96b4971e --- /dev/null +++ b/packages/core/src/services/documents/destroyDocument.ts @@ -0,0 +1,39 @@ +import { database, Database } from '$core/client' +import { NotFoundError, Result, Transaction } from '$core/lib' +import { DocumentVersionsRepository } from '$core/repositories' +import { Commit, DocumentVersion } from '$core/schema' +import { destroyOrSoftDeleteDocuments } from '$core/services/documents/destroyOrSoftDeleteDocuments' +import { assertCommitIsDraft } from '$core/services/documents/utils' + +export async function destroyDocument({ + document, + commit, + workspaceId, + db = database, +}: { + document: DocumentVersion + commit: Commit + workspaceId: number + db?: Database +}) { + return Transaction.call(async (tx) => { + const assertResult = assertCommitIsDraft(commit) + assertResult.unwrap() + + const docsScope = new DocumentVersionsRepository(workspaceId) + const documents = ( + await docsScope.getDocumentsAtCommit({ commit }) + ).unwrap() + const doc = documents.find((d) => d.documentUuid === document.documentUuid) + + if (!doc) { + return Result.error(new NotFoundError('Document does not exist')) + } + + return destroyOrSoftDeleteDocuments({ + documents: [doc], + commit, + trx: tx, + }) + }, db) +} diff --git a/packages/core/src/services/documents/destroyOrSoftDeleteDocuments.test.ts b/packages/core/src/services/documents/destroyOrSoftDeleteDocuments.test.ts new file mode 100644 index 000000000..ab3c5168c --- /dev/null +++ b/packages/core/src/services/documents/destroyOrSoftDeleteDocuments.test.ts @@ -0,0 +1,87 @@ +import { database } from '$core/client' +import { documentVersions } from '$core/schema' +import { updateDocument } from '$core/services/documents/update' +import * as factories from '$core/tests/factories' +import { and, eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' + +import { destroyOrSoftDeleteDocuments } from './destroyOrSoftDeleteDocuments' + +describe('destroyOrSoftDeleteDocuments', () => { + it('hardDestroyDocuments', async () => { + const { project } = await factories.createProject() + const { commit: draft } = await factories.createDraft({ project }) + const { documentVersion: draftDocument } = + await factories.createDocumentVersion({ + commit: draft, + path: 'doc1', + }) + + await destroyOrSoftDeleteDocuments({ + commit: draft, + documents: [draftDocument], + }).then((r) => r.unwrap()) + + const documents = await database.query.documentVersions.findMany({ + where: and(eq(documentVersions.documentUuid, draftDocument.documentUuid)), + }) + + expect(documents.length).toBe(0) + }) + + it('createDocumentsAsSoftDeleted', async () => { + const { project, documents: allDocs } = await factories.createProject({ + documents: { doc1: 'Doc 1' }, + }) + const document = allDocs[0]! + const { commit: draft } = await factories.createDraft({ project }) + + await destroyOrSoftDeleteDocuments({ + commit: draft, + documents: [document], + }).then((r) => r.unwrap()) + + const documents = await database.query.documentVersions.findMany({ + where: and(eq(documentVersions.documentUuid, document.documentUuid)), + }) + + const drafDocument = documents.find((d) => d.commitId === draft.id) + expect(documents.length).toBe(2) + expect(drafDocument!.deletedAt).not.toBe(null) + }) + + it('updateDocumetsAsSoftDeleted', async () => { + const { project, documents: allDocs } = await factories.createProject({ + documents: { doc1: 'Doc 1' }, + }) + const document = allDocs[0]! + const { commit: draft } = await factories.createDraft({ project }) + const draftDocument = await updateDocument({ + commit: draft, + document, + content: 'Doc 1 (version 2)', + }).then((r) => r.unwrap()) + + // Fake cached content exists to prove the method invalidate cache + await database + .update(documentVersions) + .set({ + resolvedContent: '[CHACHED] Doc 1 (version 1)', + }) + .where(eq(documentVersions.commitId, draft.id)) + + await destroyOrSoftDeleteDocuments({ + commit: draft, + documents: [draftDocument], + }).then((r) => r.unwrap()) + + const documents = await database.query.documentVersions.findMany({ + where: and(eq(documentVersions.documentUuid, document.documentUuid)), + }) + + const drafDocument = documents.find((d) => d.commitId === draft.id) + expect(documents.length).toBe(2) + expect(drafDocument!.resolvedContent).toBeNull() + expect(drafDocument!.deletedAt).not.toBe(null) + }) +}) diff --git a/packages/core/src/services/documents/destroyOrSoftDeleteDocuments.ts b/packages/core/src/services/documents/destroyOrSoftDeleteDocuments.ts new file mode 100644 index 000000000..d88fe708d --- /dev/null +++ b/packages/core/src/services/documents/destroyOrSoftDeleteDocuments.ts @@ -0,0 +1,156 @@ +import { omit } from 'lodash-es' + +import { database } from '$core/client' +import { Result, Transaction, TypedResult } from '$core/lib' +import { Commit, DocumentVersion, documentVersions } from '$core/schema' +import { and, eq, inArray, ne } from 'drizzle-orm' + +async function findUuidsInOtherCommits({ + tx, + documents, + commitId, +}: { + documents: DocumentVersion[] + commitId: number + tx: typeof database +}) { + const uuids = documents.map((d) => d.documentUuid) + const docs = await tx + .select() + .from(documentVersions) + .where( + and( + inArray(documentVersions.documentUuid, uuids), + ne(documentVersions.commitId, commitId), + ), + ) + + return docs.map((d) => d.documentUuid) +} + +function getToBeSoftDeleted({ + documents, + existingUuids, +}: { + documents: DocumentVersion[] + existingUuids: string[] +}) { + return documents.filter((d) => existingUuids.includes(d.documentUuid)) +} + +/** + * Destroy documents that were created in other commits in the past + * This is a hard delete to don't let shit in the DB + */ +async function hardDestroyDocuments({ + documents, + existingUuids, + tx, +}: { + documents: DocumentVersion[] + existingUuids: string[] + tx: typeof database +}) { + const uuids = documents + .filter((d) => !existingUuids.includes(d.documentUuid)) + .map((d) => d.documentUuid) + if (uuids.length === 0) return + return tx + .delete(documentVersions) + .where(inArray(documentVersions.documentUuid, uuids)) +} + +async function createDocumentsAsSoftDeleted({ + toBeSoftDeleted, + commitId, + tx, +}: { + toBeSoftDeleted: DocumentVersion[] + commitId: number + tx: typeof database +}) { + const toBeCreated = toBeSoftDeleted.filter((d) => d.commitId !== commitId) + + if (!toBeCreated.length) return + + return tx.insert(documentVersions).values( + toBeCreated.map((d) => ({ + ...omit(d, ['id', 'updatedAt', 'createdAt']), + deletedAt: new Date(), + commitId, + })), + ) +} + +async function updateDocumetsAsSoftDeleted({ + toBeSoftDeleted, + commitId, + tx, +}: { + toBeSoftDeleted: DocumentVersion[] + commitId: number + tx: typeof database +}) { + const uuids = toBeSoftDeleted + .filter((d) => d.commitId === commitId) + .map((d) => d.documentUuid) + + if (!uuids.length) return + + return tx + .update(documentVersions) + .set({ deletedAt: new Date() }) + .where( + and( + inArray(documentVersions.documentUuid, uuids), + eq(documentVersions.commitId, commitId), + ), + ) +} + +async function invalidateDocumentsCacheInCommit( + commitId: number, + tx = database, +) { + return tx + .update(documentVersions) + .set({ resolvedContent: null }) + .where(eq(documentVersions.commitId, commitId)) +} + +/** + * Destroy or soft delete documents in a commit + * A document can: + * + * 1. Not exists in previous commits. In this case, it will be hard deleted + * 1. Exists in previous commits and in the commit. It will be updated the `deletedAt` field + * 3. Exists in previous commits but not in the commit. It will be created as soft deleted + */ +export async function destroyOrSoftDeleteDocuments({ + documents, + commit, + trx = database, +}: { + documents: DocumentVersion[] + commit: Commit + trx?: typeof database +}): Promise> { + return Transaction.call(async (tx) => { + const commitId = commit.id + const existingUuids = await findUuidsInOtherCommits({ + tx, + documents, + commitId, + }) + const toBeSoftDeleted = getToBeSoftDeleted({ documents, existingUuids }) + + await Promise.all([ + hardDestroyDocuments({ documents, existingUuids, tx }), + createDocumentsAsSoftDeleted({ toBeSoftDeleted, commitId, tx }), + updateDocumetsAsSoftDeleted({ toBeSoftDeleted, commitId, tx }), + invalidateDocumentsCacheInCommit(commitId, tx), + ]) + + return Result.ok(true) + }, trx) +} diff --git a/packages/core/src/services/documents/index.ts b/packages/core/src/services/documents/index.ts index 93b37a4e6..69daabc56 100644 --- a/packages/core/src/services/documents/index.ts +++ b/packages/core/src/services/documents/index.ts @@ -1,3 +1,4 @@ export * from './create' export * from './update' +export * from './destroyDocument' export * from './recomputeChanges' diff --git a/packages/core/src/services/documents/update.test.ts b/packages/core/src/services/documents/update.test.ts index b277d130c..2defb3f46 100644 --- a/packages/core/src/services/documents/update.test.ts +++ b/packages/core/src/services/documents/update.test.ts @@ -249,7 +249,7 @@ describe('updateDocument', () => { }) const commitDocs = await docsScope - .getDocumentsAtCommit(commit) + .getDocumentsAtCommit({ commit }) .then((r) => r.unwrap()) expect(commitDocs.find((d) => d.path === 'doc1')!.resolvedContent).toBe( diff --git a/packages/core/src/services/documents/update.ts b/packages/core/src/services/documents/update.ts index 04bddb2ae..8086ac302 100644 --- a/packages/core/src/services/documents/update.ts +++ b/packages/core/src/services/documents/update.ts @@ -1,51 +1,56 @@ import { omit } from 'lodash-es' +import { database } from '$core/client' import { findWorkspaceFromCommit } from '$core/data-access' import { Result, Transaction, TypedResult } from '$core/lib' import { BadRequestError, NotFoundError } from '$core/lib/errors' import { DocumentVersionsRepository } from '$core/repositories' import { Commit, DocumentVersion, documentVersions } from '$core/schema' +import { assertCommitIsDraft } from '$core/services/documents/utils' import { eq } from 'drizzle-orm' // TODO: refactor, can be simplified -export async function updateDocument({ - commit, - document, - path, - content, - deletedAt, -}: { - commit: Commit - document: DocumentVersion - path?: string - content?: string | null - deletedAt?: Date | null -}): Promise> { +export async function updateDocument( + { + commit, + document, + path, + content, + }: { + commit: Commit + document: DocumentVersion + path?: string + content?: string | null + }, + trx = database, +): Promise> { return await Transaction.call(async (tx) => { const updatedDocData = Object.fromEntries( - Object.entries({ path, content, deletedAt }).filter( - ([_, v]) => v !== undefined, - ), + Object.entries({ path, content }).filter(([_, v]) => v !== undefined), ) + const asertResult = assertCommitIsDraft(commit) + asertResult.unwrap() + if (commit.mergedAt !== null) { return Result.error(new BadRequestError('Cannot modify a merged commit')) } const workspace = await findWorkspaceFromCommit(commit, tx) const docsScope = new DocumentVersionsRepository(workspace!.id, tx) - const currentDocs = (await docsScope.getDocumentsAtCommit(commit)).unwrap() - const currentDoc = currentDocs.find( - (d) => d.documentUuid === document.documentUuid, - ) - if (!currentDoc) { + const documents = ( + await docsScope.getDocumentsAtCommit({ commit }) + ).unwrap() + const doc = documents.find((d) => d.documentUuid === document.documentUuid) + + if (!doc) { return Result.error(new NotFoundError('Document does not exist')) } if (path !== undefined) { if ( - currentDocs.find( - (d) => d.path === path && d.documentUuid !== document.documentUuid, + documents.find( + (d) => d.path === path && d.documentUuid !== doc.documentUuid, ) ) { return Result.error( @@ -54,7 +59,8 @@ export async function updateDocument({ } } - const oldVersion = omit(currentDoc, ['id', 'commitId', 'updatedAt']) + const oldVersion = omit(document, ['id', 'commitId', 'updatedAt']) + const newVersion = { ...oldVersion, ...updatedDocData, @@ -80,5 +86,5 @@ export async function updateDocument({ .where(eq(documentVersions.commitId, commit.id)) return Result.ok(updatedDocs[0]!) - }) + }, trx) } diff --git a/packages/core/src/services/documents/utils.ts b/packages/core/src/services/documents/utils.ts index 8801ad2db..2e5c31f54 100644 --- a/packages/core/src/services/documents/utils.ts +++ b/packages/core/src/services/documents/utils.ts @@ -4,6 +4,7 @@ import { readMetadata, type CompileError } from '@latitude-data/compiler' import { database } from '$core/client' import { findWorkspaceFromCommit } from '$core/data-access' import { Result, Transaction, TypedResult } from '$core/lib' +import { BadRequestError } from '$core/lib/errors' import { CommitsRepository, DocumentVersionsRepository, @@ -34,9 +35,9 @@ export async function getMergedAndDraftDocuments( ) if (headCommitResult.error) return headCommitResult - const headDocumentsResult = await docsScope.getDocumentsAtCommit( - headCommitResult.value, - ) + const headDocumentsResult = await docsScope.getDocumentsAtCommit({ + commit: headCommitResult.value, + }) if (headDocumentsResult.error) return Result.error(headDocumentsResult.error) mergedDocuments.push(...headDocumentsResult.value) @@ -66,6 +67,13 @@ export function existsAnotherDocumentWithSamePath({ return documents.find((d) => d.path === path) !== undefined } +export function assertCommitIsDraft(commit: Commit) { + if (commit.mergedAt !== null) { + return Result.error(new BadRequestError('Cannot modify a merged commit')) + } + return Result.ok(true) +} + export async function resolveDocumentChanges({ originalDocuments, newDocuments, diff --git a/packages/core/src/tests/factories/commits.ts b/packages/core/src/tests/factories/commits.ts index 43d5bf416..7e0a2c122 100644 --- a/packages/core/src/tests/factories/commits.ts +++ b/packages/core/src/tests/factories/commits.ts @@ -12,7 +12,7 @@ export async function createDraft({ project }: Partial = {}) { ? project.id : (await createProject(project)).project.id - const result = await createCommitFn({ commit: { projectId } }) + const result = await createCommitFn({ commit: { projectId: projectId! } }) const commit = result.unwrap() return { commit } diff --git a/packages/core/src/tests/factories/documents.ts b/packages/core/src/tests/factories/documents.ts index 8af4765e3..cbe0446f4 100644 --- a/packages/core/src/tests/factories/documents.ts +++ b/packages/core/src/tests/factories/documents.ts @@ -1,7 +1,9 @@ import { faker } from '@faker-js/faker' -import type { Commit } from '$core/schema' +import { database } from '$core/client' +import { documentVersions, type Commit } from '$core/schema' import { createNewDocument } from '$core/services/documents/create' import { updateDocument } from '$core/services/documents/update' +import { eq } from 'drizzle-orm' export type IDocumentVersionData = { commit: Commit @@ -16,6 +18,13 @@ function makeRandomDocumentVersionData() { } } +export async function markAsSoftDelete(documentUuid: string, tx = database) { + return tx + .update(documentVersions) + .set({ deletedAt: new Date() }) + .where(eq(documentVersions.documentUuid, documentUuid)) +} + export async function createDocumentVersion( documentData: IDocumentVersionData, ) { diff --git a/packages/core/src/tests/factories/projects.ts b/packages/core/src/tests/factories/projects.ts index cb7a77a7d..b4768ff40 100644 --- a/packages/core/src/tests/factories/projects.ts +++ b/packages/core/src/tests/factories/projects.ts @@ -1,5 +1,6 @@ import { faker } from '@faker-js/faker' import { unsafelyGetUser } from '$core/data-access' +import { CommitsRepository } from '$core/repositories' import { DocumentVersion, Workspace, type SafeUser } from '$core/schema' import { createNewDocument, mergeCommit, updateDocument } from '$core/services' import { createProject as createProjectFn } from '$core/services/projects' @@ -61,6 +62,8 @@ export async function createProject(projectData: Partial = {}) { workspaceId: workspace.id, }) const project = result.unwrap() + const commitsScope = new CommitsRepository(workspace.id) + let commit = (await commitsScope.getFirstCommitForProject(project)).unwrap() const documents: DocumentVersion[] = [] @@ -80,8 +83,8 @@ export async function createProject(projectData: Partial = {}) { }) documents.push(updatedDoc.unwrap()) } - await mergeCommit(draft).then((r) => r.unwrap()) + commit = await mergeCommit(draft).then((r) => r.unwrap()) } - return { project, user, workspace, documents } + return { project, user, workspace, documents, commit: commit! } } diff --git a/packages/core/src/tests/setup.ts b/packages/core/src/tests/setup.ts index 3a9239329..fb48d5158 100644 --- a/packages/core/src/tests/setup.ts +++ b/packages/core/src/tests/setup.ts @@ -1,4 +1,5 @@ // vitest-env.d.ts + import { beforeEach } from 'vitest' import * as factories from './factories' diff --git a/packages/web-ui/src/sections/Document/Editor/index.tsx b/packages/web-ui/src/sections/Document/Editor/index.tsx index a7611e299..ead50b2db 100644 --- a/packages/web-ui/src/sections/Document/Editor/index.tsx +++ b/packages/web-ui/src/sections/Document/Editor/index.tsx @@ -81,8 +81,11 @@ export default function DocumentEditor({
Inputs {inputs.length > 0 ? ( - inputs.map((param) => ( -
+ inputs.map((param, idx) => ( +
{{{param}}} diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/FilesProvider/index.tsx b/packages/web-ui/src/sections/Document/Sidebar/Files/FilesProvider/index.tsx index 23c65953b..298f90a33 100644 --- a/packages/web-ui/src/sections/Document/Sidebar/Files/FilesProvider/index.tsx +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/FilesProvider/index.tsx @@ -2,9 +2,9 @@ import { createContext, ReactNode, useContext } from 'react' type IFilesContext = { currentPath?: string - onCreateFile: (path: string) => void - onDeleteFile: (documentUuid: string) => void - onDeleteFolder: (path: string) => void + onCreateFile: (path: string) => Promise + onDeleteFile: (documentUuid: string) => Promise + onDeleteFolder: (path: string) => Promise onNavigateToDocument: (documentUuid: string) => void } const FileTreeContext = createContext({} as IFilesContext) diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/index.tsx b/packages/web-ui/src/sections/Document/Sidebar/Files/index.tsx index ec2d8018d..19d628676 100644 --- a/packages/web-ui/src/sections/Document/Sidebar/Files/index.tsx +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/index.tsx @@ -15,14 +15,11 @@ import { Icons } from '$ui/ds/atoms/Icons' import { Input } from '$ui/ds/atoms/Input' import Text from '$ui/ds/atoms/Text' import { cn } from '$ui/lib/utils' -import { - FileTreeProvider, - useFileTreeContext, -} from '$ui/sections/Document/Sidebar/Files/FilesProvider' -import { useNodeValidator } from '$ui/sections/Document/Sidebar/Files/useNodeValidator' -import { useTempNodes } from '$ui/sections/Document/Sidebar/Files/useTempNodes' +import { FileTreeProvider, useFileTreeContext } from './FilesProvider' +import { useNodeValidator } from './useNodeValidator' import { useOpenPaths } from './useOpenPaths' +import { useTempNodes } from './useTempNodes' import { Node, SidebarDocument, useTree } from './useTree' const ICON_CLASS = 'min-w-6 h-6 text-muted-foreground' @@ -104,20 +101,9 @@ function FolderHeader({ indentation: IndentType[] onToggleOpen: () => void }) { + const { onDeleteFolder } = useFileTreeContext() const inputRef = useRef(null) const nodeRef = useRef(null) - const { onDeleteFolder } = useFileTreeContext() - const { openPaths, togglePath } = useOpenPaths((state) => ({ - togglePath: state.togglePath, - openPaths: state.openPaths, - })) - const { addFolder, updateFolder, deleteTmpFolder } = useTempNodes( - (state) => ({ - addFolder: state.addFolder, - updateFolder: state.updateFolder, - deleteTmpFolder: state.deleteTmpFolder, - }), - ) const { isEditing, error, onInputChange, onInputKeyDown, keepFocused } = useNodeValidator({ node, @@ -130,23 +116,33 @@ function FolderHeader({ deleteTmpFolder({ id: node.id }) }, }) + const { openPaths, togglePath } = useOpenPaths((state) => ({ + togglePath: state.togglePath, + openPaths: state.openPaths, + })) + const { addFolder, updateFolder, deleteTmpFolder } = useTempNodes( + (state) => ({ + addFolder: state.addFolder, + updateFolder: state.updateFolder, + deleteTmpFolder: state.deleteTmpFolder, + }), + ) + const onAddNode = useCallback( + ({ isFile }: { isFile: boolean }) => + () => { + if (!open) { + togglePath(node.path) + } + addFolder({ parentPath: node.path, parentId: node.id, isFile }) + }, + [node.path, togglePath, open], + ) const FolderIcon = open ? Icons.folderOpen : Icons.folderClose const ChevronIcon = open ? Icons.chevronDown : Icons.chevronRight const options = useMemo( () => [ - { - label: 'New folder', - onClick: () => { - if (!open) { - togglePath(node.path) - } - addFolder({ parentPath: node.path, parentId: node.id }) - }, - }, - { - label: 'New Prompt', - onClick: () => {}, - }, + { label: 'New folder', onClick: onAddNode({ isFile: false }) }, + { label: 'New Prompt', onClick: onAddNode({ isFile: true }) }, { label: 'Delete folder', type: 'destructive', @@ -229,12 +225,34 @@ function FileHeader({ node: Node indentation: IndentType[] }) { - const { onDeleteFile, onNavigateToDocument } = useFileTreeContext() + const { onNavigateToDocument, onDeleteFile, onCreateFile } = + useFileTreeContext() + const { deleteTmpFolder, reset } = useTempNodes((state) => ({ + reset: state.reset, + deleteTmpFolder: state.deleteTmpFolder, + })) + const inputRef = useRef(null) + const nodeRef = useRef(null) + const { isEditing, error, onInputChange, onInputKeyDown, keepFocused } = + useNodeValidator({ + node, + nodeRef, + inputRef, + saveValue: async ({ path }: { path: string }) => { + const parentPath = node.path.split('/').slice(0, -1).join('/') + await onCreateFile(`${parentPath}/${path}`) + reset() + }, + leaveWithoutSave: () => { + deleteTmpFolder({ id: node.id }) + }, + }) const handleClick = useCallback(() => { if (selected) return + if (!node.isPersisted) return onNavigateToDocument(node.doc!.documentUuid) - }, [node.doc!.documentUuid, selected]) + }, [node.doc!.documentUuid, selected, node.isPersisted]) const options = useMemo( () => [ { @@ -249,6 +267,7 @@ function FileHeader({ ) return ( -
- - {node.name} - -
+ {isEditing ? ( +
+ +
+ ) : ( +
+ + {node.name} + +
+ )}
@@ -384,7 +420,11 @@ export function FilesTree({ currentPath, documents, navigateToDocument, + createFile, + destroyFile, }: { + createFile: (args: { path: string }) => Promise + destroyFile: (documentUuid: string) => Promise documents: SidebarDocument[] currentPath: string | undefined navigateToDocument: (documentUuid: string) => void @@ -402,13 +442,11 @@ export function FilesTree({ { - console.log('onCreateFile', path) - }} - onDeleteFile={(documentUuid) => { - console.log('onDeleteFile', documentUuid) + onCreateFile={async (path) => { + createFile({ path }) }} - onDeleteFolder={(path) => { + onDeleteFile={destroyFile} + onDeleteFolder={async (path) => { console.log('onDeleteFolder', path) }} > diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/useTempNodes/index.test.ts b/packages/web-ui/src/sections/Document/Sidebar/Files/useTempNodes/index.test.ts index 5a3008d37..9f728e17f 100644 --- a/packages/web-ui/src/sections/Document/Sidebar/Files/useTempNodes/index.test.ts +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/useTempNodes/index.test.ts @@ -15,6 +15,7 @@ describe('useTempNodes', () => { result.current.addFolder({ parentPath: 'some-folder', parentId: 'fake-id', + isFile: false, }), ) @@ -24,6 +25,7 @@ describe('useTempNodes', () => { id: expect.any(String), path: 'some-folder/ ', name: ' ', + isFile: false, isPersisted: false, }), ], @@ -35,6 +37,7 @@ describe('useTempNodes', () => { act(() => result.current.addFolder({ parentPath: 'some-folder', + isFile: false, parentId: 'fake-id', }), ) @@ -47,6 +50,7 @@ describe('useTempNodes', () => { id: expect.any(String), path: 'some-folder/new-name', name: 'new-name', + isFile: false, isPersisted: false, }), ], @@ -59,6 +63,7 @@ describe('useTempNodes', () => { result.current.addFolder({ parentPath: 'some-folder', parentId: 'fake-id', + isFile: false, }), ) const id = result?.current?.tmpFolders?.['some-folder']?.[0]?.id @@ -73,6 +78,7 @@ describe('useTempNodes', () => { result.current.addFolder({ parentPath: 'some-folder', parentId: 'fake-id', + isFile: false, }), ) const id = result?.current?.tmpFolders?.['some-folder']?.[0]?.id @@ -84,6 +90,7 @@ describe('useTempNodes', () => { result.current.addFolder({ parentPath: 'some-folder/parent-tmp-folder', parentId: id!, + isFile: false, }), ) @@ -101,6 +108,7 @@ describe('useTempNodes', () => { path: 'some-folder/parent-tmp-folder', name: 'parent-tmp-folder', isPersisted: false, + isFile: false, children: [], }), ], @@ -113,6 +121,7 @@ describe('useTempNodes', () => { result.current.addFolder({ parentPath: 'some-folder', parentId: 'fake-id', + isFile: false, }), ) const id = result?.current?.tmpFolders?.['some-folder']?.[0]?.id @@ -124,6 +133,7 @@ describe('useTempNodes', () => { result.current.addFolder({ parentPath: 'some-folder/parent-tmp-folder', parentId: id!, + isFile: false, }), ) @@ -139,6 +149,7 @@ describe('useTempNodes', () => { path: 'some-folder/parent-tmp-folder/child-tmp-folder', name: 'child-tmp-folder', isPersisted: false, + isFile: false, isRoot: false, }) const rootTmpNode = new Node({ @@ -146,6 +157,7 @@ describe('useTempNodes', () => { path: 'some-folder/parent-tmp-folder', name: 'parent-tmp-folder', isPersisted: false, + isFile: false, children: [child], }) child.parent = rootTmpNode diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/useTempNodes/index.ts b/packages/web-ui/src/sections/Document/Sidebar/Files/useTempNodes/index.ts index a0a7a7c65..9fd37d811 100644 --- a/packages/web-ui/src/sections/Document/Sidebar/Files/useTempNodes/index.ts +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/useTempNodes/index.ts @@ -4,7 +4,11 @@ import { defaultGenerateNodeUuid, Node } from '../useTree' type TmpFoldersState = { tmpFolders: Record - addFolder: (args: { parentPath: string; parentId: string }) => void + addFolder: (args: { + parentPath: string + parentId: string + isFile: boolean + }) => void updateFolder: (args: { id: string; path: string }) => void deleteTmpFolder: (args: { id: string }) => void reset: () => void @@ -23,16 +27,21 @@ function allTmpNodes(tmpFolders: TmpFoldersState['tmpFolders']) { function createEmptyNode({ parentPath, parent, + isFile, }: { parentPath: string + isFile: boolean parent: Node | undefined }) { const emptyName = ' ' + const path = `${parentPath}/${emptyName}` return new Node({ id: defaultGenerateNodeUuid(), name: emptyName, - path: `${parentPath}/${emptyName}`, + path, isPersisted: false, + doc: isFile ? { path, documentUuid: defaultGenerateNodeUuid() } : undefined, + isFile, parent, }) } @@ -57,6 +66,7 @@ function cloneNode(node: Node) { name: node.name, path: node.path, parent: node.parent, + isFile: node.isFile, isPersisted: node.isPersisted, }) return clonedNode @@ -84,7 +94,7 @@ export const useTempNodes = create((set) => ({ tmpFolders: {}, }) }, - addFolder: ({ parentPath, parentId }) => { + addFolder: ({ parentPath, parentId, isFile }) => { set((state) => { const allNodes = allTmpNodes(state.tmpFolders) const parentNode = allNodes.find((node) => node.id === parentId) @@ -92,6 +102,7 @@ export const useTempNodes = create((set) => ({ const node = createEmptyNode({ parentPath: parentNode ? parentNode.path : parentPath, parent: parentNode, + isFile, }) // When adding to an existing tmp node we diff --git a/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.ts b/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.ts index dce789361..74317c324 100644 --- a/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.ts +++ b/packages/web-ui/src/sections/Document/Sidebar/Files/useTree/index.ts @@ -24,11 +24,13 @@ export class Node { isPersisted, children = [], isRoot = false, + isFile, path, name = '', }: { id: string path: string + isFile: boolean parent?: Node isPersisted: boolean doc?: SidebarDocument @@ -42,7 +44,7 @@ export class Node { this.isPersisted = isPersisted this.name = isRoot ? 'root' : name this.isRoot = isRoot - this.isFile = !!doc + this.isFile = isFile this.children = children this.doc = doc } @@ -102,6 +104,7 @@ function buildTree({ const node = new Node({ id: generateNodeId({ uuid }), isPersisted: true, + isFile, name: segment, path, doc: file, @@ -146,6 +149,7 @@ export function useTree({ path: '', children: [], isRoot: true, + isFile: false, }) const nodeMap = new Map() nodeMap.set('', root)