diff --git a/apps/web/src/actions/documents/create.ts b/apps/web/src/actions/documents/create.ts index 032acdc05..7f83166d8 100644 --- a/apps/web/src/actions/documents/create.ts +++ b/apps/web/src/actions/documents/create.ts @@ -1,6 +1,6 @@ 'use server' -import { createDocumentVersion } from '@latitude-data/core' +import { createNewDocument, findCommitByUuid } from '@latitude-data/core' import { z } from 'zod' import { withProject } from '../procedures' @@ -15,6 +15,13 @@ export const createDocumentVersionAction = withProject { type: 'json' }, ) .handler(async ({ input }) => { - const result = await createDocumentVersion(input) + const commit = await findCommitByUuid({ + projectId: input.projectId, + uuid: input.commitUuid, + }).then((r) => r.unwrap()) + const result = await createNewDocument({ + commitId: commit.id, + path: input.path, + }) 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 389cebf5b..a5e0976d2 100644 --- a/apps/web/src/app/(private)/_data-access/index.ts +++ b/apps/web/src/app/(private)/_data-access/index.ts @@ -1,10 +1,10 @@ import { cache } from 'react' import { - findCommit as originalfindCommit, + findCommitByUuid as originalfindCommit, findProject as originalFindProject, getFirstProject as originalGetFirstProject, - type FindCommitProps, + type FindCommitByUuidProps, type FindProjectProps, } from '@latitude-data/core' @@ -27,7 +27,7 @@ export const findProject = cache( ) export const findCommit = cache( - async ({ uuid, projectId }: FindCommitProps) => { + async ({ uuid, projectId }: FindCommitByUuidProps) => { const result = await originalfindCommit({ uuid, projectId }) const commit = result.unwrap() diff --git a/apps/web/src/components/Sidebar/index.tsx b/apps/web/src/components/Sidebar/index.tsx index ff92b79b3..969b82c14 100644 --- a/apps/web/src/components/Sidebar/index.tsx +++ b/apps/web/src/components/Sidebar/index.tsx @@ -1,4 +1,4 @@ -import { getDocumentsAtCommit } from '@latitude-data/core' +import { findCommitByUuid, getDocumentsAtCommit } from '@latitude-data/core' import DocumentTree, { CreateNode } from './DocumentTree' @@ -9,9 +9,9 @@ export default async function Sidebar({ commitUuid: string projectId: number }) { + const commit = await findCommitByUuid({ projectId, uuid: commitUuid }) const documentsResult = await getDocumentsAtCommit({ - projectId, - commitUuid, + commitId: commit.unwrap().id, }) const documents = documentsResult.unwrap() diff --git a/packages/compiler/package.json b/packages/compiler/package.json index e74dd2279..914e6784d 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -37,7 +37,6 @@ "rollup": "^4.10.0", "rollup-plugin-dts": "^6.1.1", "typescript": "^5.2.2", - "vite-tsconfig-paths": "^4.3.2", "vitest": "^1.2.2" } } diff --git a/packages/compiler/vitest.config.ts b/packages/compiler/vitest.config.ts index d684a919f..10832c54b 100644 --- a/packages/compiler/vitest.config.ts +++ b/packages/compiler/vitest.config.ts @@ -1,8 +1,17 @@ -import tsconfigPaths from 'vite-tsconfig-paths' +import { dirname } from 'path' +import { fileURLToPath } from 'url' + import { defineConfig } from 'vitest/config' +const filename = fileURLToPath(import.meta.url) +const root = dirname(filename) + export default defineConfig({ - plugins: [tsconfigPaths()], + resolve: { + alias: { + $compiler: `${root}/src`, + }, + }, test: { globals: true, environment: 'node', diff --git a/packages/core/package.json b/packages/core/package.json index 941a2054a..7378781e5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,6 +23,7 @@ "prettier": "prettier --write src/**/*.ts" }, "dependencies": { + "@latitude-data/compiler": "workspace:^", "@latitude-data/env": "workspace:^", "@t3-oss/env-core": "^0.10.1", "bcrypt": "^5.1.1", @@ -41,7 +42,6 @@ "eslint-plugin-drizzle": "^0.2.3", "pg-transactional-tests": "^1.0.9", "supertest": "^7.0.0", - "vite-tsconfig-paths": "^4.3.2", "vitest": "^2.0.3" } } diff --git a/packages/core/src/data-access/commits.ts b/packages/core/src/data-access/commits.ts index 613e5a562..a111ee1c6 100644 --- a/packages/core/src/data-access/commits.ts +++ b/packages/core/src/data-access/commits.ts @@ -24,12 +24,12 @@ export async function findHeadCommit( return Result.ok(headCommit) } -export type FindCommitProps = { +export type FindCommitByUuidProps = { uuid: string projectId?: number } -export async function findCommit( - { projectId, uuid }: FindCommitProps, +export async function findCommitByUuid( + { projectId, uuid }: FindCommitByUuidProps, tx = database, ): Promise> { if (uuid === HEAD_COMMIT) { @@ -49,34 +49,19 @@ export async function findCommit( return Result.ok(commit) } -export async function listCommits() { - return database.select().from(commits) -} - -export async function getCommitMergedAt( - { projectId, commitUuid }: { projectId: number; commitUuid: string }, +export async function findCommitById( + { id }: { id: number }, 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!) - } - +): Promise> { const commit = await tx.query.commits.findFirst({ - where: eq(commits.uuid, commitUuid), + where: eq(commits.id, id), }) if (!commit) return Result.error(new NotFoundError('Commit not found')) - return Result.ok(commit.mergedAt) + return Result.ok(commit) +} + +export async function listCommits() { + return database.select().from(commits) } diff --git a/packages/core/src/data-access/documentVersions/getDocumentByPath.ts b/packages/core/src/data-access/documentVersions/getDocumentByPath.ts index ca48c355f..7b4864aa4 100644 --- a/packages/core/src/data-access/documentVersions/getDocumentByPath.ts +++ b/packages/core/src/data-access/documentVersions/getDocumentByPath.ts @@ -1,6 +1,7 @@ import { database } from '$core/client' import { NotFoundError, Result } from '$core/lib' +import { findCommitByUuid } from '../commits' import { getDocumentsAtCommit } from './getDocumentsAtCommit' export async function getDocumentByPath( @@ -16,7 +17,9 @@ export async function getDocumentByPath( db = database, ) { try { - const result = await getDocumentsAtCommit({ projectId, commitUuid }, db) + const commit = await findCommitByUuid({ projectId, uuid: commitUuid }, db) + if (commit.error) return commit + const result = await getDocumentsAtCommit({ commitId: commit.value.id }, db) const documents = result.unwrap() const document = documents.find((doc) => doc.path === path) if (!document) { diff --git a/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.test.ts b/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.test.ts index 54100135f..37d1903c1 100644 --- a/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.test.ts +++ b/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.test.ts @@ -1,8 +1,10 @@ import { HEAD_COMMIT } from '$core/constants' +import { modifyExistingDocument } from '$core/services' import { mergeCommit } from '$core/services/commits/merge' import useTestDatabase from '$core/tests/useTestDatabase' import { describe, expect, it } from 'vitest' +import { findCommitByUuid } from '../commits' import { getDocumentsAtCommit } from './getDocumentsAtCommit' useTestDatabase() @@ -17,8 +19,7 @@ describe('getDocumentsAtCommit', () => { await mergeCommit({ commitId: commit.id }) const result = await getDocumentsAtCommit({ - commitUuid: commit.uuid, - projectId: project.id, + commitId: commit.id, }) const documents = result.unwrap() @@ -34,57 +35,51 @@ describe('getDocumentsAtCommit', () => { commit: commit1, content: 'VERSION 1', }) + await mergeCommit({ commitId: commit1.id }).then((r) => r.unwrap()) const { commit: commit2 } = await ctx.factories.createDraft({ project }) - await ctx.factories.createDocumentVersion({ - commit: commit2, + await modifyExistingDocument({ + commitId: commit2.id, documentUuid: doc.documentUuid, content: 'VERSION 2', - }) + }).then((r) => r.unwrap()) const { commit: commit3 } = await ctx.factories.createDraft({ project }) - await ctx.factories.createDocumentVersion({ - commit: commit3, + await modifyExistingDocument({ + commitId: commit3.id, documentUuid: doc.documentUuid, - content: 'VERSION 3', - }) + content: 'VERSION 3 (draft)', + }).then((r) => r.unwrap()) - // Commit 1 is merged AFTER commit 2 - // Commit 3 is not merged - await mergeCommit({ commitId: commit2.id }) - await mergeCommit({ commitId: commit1.id }) + await mergeCommit({ commitId: commit2.id }).then((r) => r.unwrap()) - const commit1Result = await getDocumentsAtCommit({ - commitUuid: commit1.uuid, - projectId: project.id, - }) - const commit1Docs = commit1Result.unwrap() + const commit1Docs = await getDocumentsAtCommit({ + commitId: commit1.id, + }).then((r) => r.unwrap()) expect(commit1Docs.length).toBe(1) expect(commit1Docs[0]!.content).toBe('VERSION 1') - const commit2Result = await getDocumentsAtCommit({ - commitUuid: commit2.uuid, - projectId: project.id, - }) - const commit2Docs = commit2Result.unwrap() + const commit2Docs = await getDocumentsAtCommit({ + commitId: commit2.id, + }).then((r) => r.unwrap()) expect(commit2Docs.length).toBe(1) expect(commit2Docs[0]!.content).toBe('VERSION 2') - const commit3Result = await getDocumentsAtCommit({ - commitUuid: commit3.uuid, - projectId: project.id, - }) - const commit3Docs = commit3Result.unwrap() + const commit3Docs = await getDocumentsAtCommit({ + commitId: commit3.id, + }).then((r) => r.unwrap()) expect(commit3Docs.length).toBe(1) - expect(commit3Docs[0]!.content).toBe('VERSION 3') + expect(commit3Docs[0]!.content).toBe('VERSION 3 (draft)') - const headResult = await getDocumentsAtCommit({ - commitUuid: HEAD_COMMIT, + const headCommit = await findCommitByUuid({ projectId: project.id, - }) - const headDocs = headResult.unwrap() + uuid: HEAD_COMMIT, + }).then((r) => r.unwrap()) + const headDocs = await getDocumentsAtCommit({ + commitId: headCommit.id, + }).then((r) => r.unwrap()) expect(headDocs.length).toBe(1) - expect(headDocs[0]!.content).toBe('VERSION 1') + expect(headDocs[0]!.content).toBe('VERSION 2') }) it('returns documents that were last modified in a previous commit', async (ctx) => { @@ -95,55 +90,52 @@ describe('getDocumentsAtCommit', () => { commit: commit1, content: 'Doc 1 commit 1', }) - await mergeCommit({ commitId: commit1.id }) + await mergeCommit({ commitId: commit1.id }).then((r) => r.unwrap()) 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({ commitId: commit2.id }).then((r) => r.unwrap()) const { commit: commit3 } = await ctx.factories.createDraft({ project }) - await ctx.factories.createDocumentVersion({ - commit: commit3, + await modifyExistingDocument({ + commitId: commit3.id, documentUuid: doc2.documentUuid, content: 'Doc 2 commit 3 (draft)', - }) + }).then((r) => r.unwrap()) - const commit1Result = await getDocumentsAtCommit({ - commitUuid: commit1.uuid, - projectId: project.id, - }) - const commit1Docs = commit1Result.unwrap() + const commit1Docs = await getDocumentsAtCommit({ + commitId: commit1.id, + }).then((r) => r.unwrap()) expect(commit1Docs.length).toBe(1) const commit1DocContents = commit1Docs.map((d) => d.content) expect(commit1DocContents).toContain('Doc 1 commit 1') - const commit2Result = await getDocumentsAtCommit({ - commitUuid: commit2.uuid, - projectId: project.id, - }) - const commit2Docs = commit2Result.unwrap() + const commit2Docs = await getDocumentsAtCommit({ + commitId: commit2.id, + }).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 commit3Result = await getDocumentsAtCommit({ - commitUuid: commit3.uuid, - projectId: project.id, - }) - const commit3Docs = commit3Result.unwrap() + const commit3Docs = await getDocumentsAtCommit({ + commitId: commit3.id, + }).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 headResult = await getDocumentsAtCommit({ - commitUuid: HEAD_COMMIT, + const headCommit = await findCommitByUuid({ projectId: project.id, + uuid: HEAD_COMMIT, }) - const headDocs = headResult.unwrap() + const headDocs = await getDocumentsAtCommit({ + commitId: headCommit.unwrap().id, + }).then((r) => r.unwrap()) expect(headDocs.length).toBe(2) const headDocContents = headDocs.map((d) => d.content) expect(headDocContents).toContain('Doc 1 commit 1') diff --git a/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.ts b/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.ts index d0e186a24..1e8730edc 100644 --- a/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.ts +++ b/packages/core/src/data-access/documentVersions/getDocumentsAtCommit.ts @@ -3,27 +3,26 @@ import { database, DocumentVersion, documentVersions, - findCommit, - getCommitMergedAt, + findCommitById, Result, TypedResult, } from '@latitude-data/core' import { LatitudeError } from '$core/lib/errors' -import { and, eq, isNotNull, lte, max } from 'drizzle-orm' +import { and, eq, getTableColumns, isNotNull, lte, max } from 'drizzle-orm' -export async function getDocumentsAtCommit( - { commitUuid, projectId }: { commitUuid: string; projectId: number }, +async function fetchDocumentsFromMergedCommits( + { + projectId, + maxMergedAt, + }: { + projectId: number + maxMergedAt: Date | null + }, tx = database, -): Promise> { - const maxMergedAtResult = await getCommitMergedAt({ commitUuid, projectId }) - if (maxMergedAtResult.error) return maxMergedAtResult - const maxMergedAt = maxMergedAtResult.unwrap() - - const whereStatement = () => { +): Promise { + const filterByMaxMergedAt = () => { const mergedAtNotNull = isNotNull(commits.mergedAt) - if (!maxMergedAt) { - return mergedAtNotNull - } + if (maxMergedAt === null) return mergedAtNotNull return and(mergedAtNotNull, lte(commits.mergedAt, maxMergedAt)) } @@ -35,13 +34,13 @@ export async function getDocumentsAtCommit( }) .from(documentVersions) .innerJoin(commits, eq(commits.id, documentVersions.commitId)) - .where(whereStatement()) + .where(and(filterByMaxMergedAt(), eq(commits.projectId, projectId))) .groupBy(documentVersions.documentUuid), ) - const documentsAtPreviousMergedCommitsResult = await tx + const documentsFromMergedCommits = await tx .with(lastVersionOfEachDocument) - .select() + .select(getTableColumns(documentVersions)) .from(documentVersions) .innerJoin( commits, @@ -61,33 +60,60 @@ export async function getDocumentsAtCommit( ), ) - const documentsAtPreviousMergedCommits = - documentsAtPreviousMergedCommitsResult.map((d) => d.document_versions) + return documentsFromMergedCommits +} - if (maxMergedAt) { - // Referenced commit is merged. No additional documents to return. - return Result.ok(documentsAtPreviousMergedCommits) - } +function mergeDocuments( + ...documentsArr: DocumentVersion[][] +): DocumentVersion[] { + return documentsArr.reduce((acc, documents) => { + return acc + .filter((d) => { + return !documents.find((d2) => d2.documentUuid === d.documentUuid) + }) + .concat(documents) + }, []) +} - const commitResult = await findCommit({ projectId, uuid: commitUuid }) +export async function getDocumentsAtCommit( + { commitId }: { commitId: number }, + tx = database, +): Promise> { + const commitResult = await findCommitById({ id: commitId }) if (commitResult.error) return commitResult + const commit = commitResult.value! - const commit = commitResult.unwrap() + const documentsFromMergedCommits = await fetchDocumentsFromMergedCommits({ + projectId: commit.projectId, + maxMergedAt: commit.mergedAt, + }) - const documentsAtDraftResult = await tx - .select() + if (commit.mergedAt !== null) { + // Referenced commit is merged. No additional documents to return. + return Result.ok(documentsFromMergedCommits) + } + + const documentsFromDraft = await tx + .select(getTableColumns(documentVersions)) .from(documentVersions) .innerJoin(commits, eq(commits.id, documentVersions.commitId)) - .where(eq(commits.id, commit.id)) + .where(eq(commits.id, commitId)) - const documentsAtDraft = documentsAtDraftResult.map( - (d) => d.document_versions, + const totalDocuments = mergeDocuments( + documentsFromMergedCommits, + documentsFromDraft, ) - const totalDocuments = documentsAtPreviousMergedCommits - .filter((d) => - documentsAtDraft.find((d2) => d2.documentUuid !== d.documentUuid), - ) - .concat(documentsAtDraft) return Result.ok(totalDocuments) } + +export async function listCommitChanges( + { commitId }: { commitId: number }, + tx = database, +) { + const changedDocuments = await tx.query.documentVersions.findMany({ + where: eq(documentVersions.commitId, commitId), + }) + + return Result.ok(changedDocuments) +} diff --git a/packages/core/src/services/documentVersions/create.ts b/packages/core/src/services/documentVersions/create.ts deleted file mode 100644 index d2c8a02fe..000000000 --- a/packages/core/src/services/documentVersions/create.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { - DocumentVersion, - documentVersions, - findCommit, - Result, - Transaction, -} from '@latitude-data/core' -import { ForbiddenError } from '$core/lib/errors' - -function createDocument({ - path, - commitId, - documentUuid, - content, -}: { - path: string - commitId: number - content: string - documentUuid?: string -}) { - return Transaction.call(async (tx) => { - const result = await tx - .insert(documentVersions) - .values({ - path, - commitId, - documentUuid, - content, - }) - .returning() - const documentVersion = result[0] - return Result.ok(documentVersion!) - }) -} - -export async function createDocumentVersion({ - documentUuid, - projectId, - path, - commitUuid, - content, -}: { - documentUuid?: string - projectId: number - path: string - commitUuid: string - content?: string -}) { - const resultFindCommit = await findCommit({ uuid: commitUuid, projectId }) - - if (resultFindCommit.error) return resultFindCommit - - const commit = resultFindCommit.value - - if (commit.mergedAt !== null) { - return Result.error( - new ForbiddenError('Cannot create a document version in a merged commit'), - ) - } - - return createDocument({ - documentUuid, - path, - commitId: commit.id, - content: content ?? '', - }) -} diff --git a/packages/core/src/services/documents/create.test.ts b/packages/core/src/services/documents/create.test.ts new file mode 100644 index 000000000..6ad78364e --- /dev/null +++ b/packages/core/src/services/documents/create.test.ts @@ -0,0 +1,64 @@ +import { listCommitChanges } from '$core/data-access' +import useTestDatabase from '$core/tests/useTestDatabase' +import { describe, expect, it } from 'vitest' + +import { mergeCommit } from '../commits/merge' +import { createNewDocument } from './create' + +useTestDatabase() + +describe('createNewDocument', () => { + it('creates a new document version in the commit', async (ctx) => { + const { project } = await ctx.factories.createProject() + const { commit } = await ctx.factories.createDraft({ project }) + + const documentResult = await createNewDocument({ + commitId: commit.id, + path: 'foo', + }) + + const document = documentResult.unwrap() + expect(document.path).toBe('foo') + + const commitChanges = await listCommitChanges({ commitId: commit.id }) + expect(commitChanges.value.length).toBe(1) + expect(commitChanges.value[0]!.documentUuid).toBe(document.documentUuid) + expect(commitChanges.value[0]!.path).toBe(document.path) + }) + + it('fails if there is another document with the same path', async (ctx) => { + const { project } = await ctx.factories.createProject() + const { commit } = await ctx.factories.createDraft({ project }) + + await createNewDocument({ + commitId: commit.id, + path: 'foo', + }) + + const result = await createNewDocument({ + commitId: commit.id, + path: 'foo', + }) + + expect(result.ok).toBe(false) + expect(result.error!.message).toBe( + 'A document with the same path already exists', + ) + }) + + it('fails when trying to create a document in a merged commit', async (ctx) => { + const { project } = await ctx.factories.createProject() + const { commit } = await ctx.factories.createDraft({ project }) + await mergeCommit({ commitId: commit.id }) + + const result = await createNewDocument({ + commitId: commit.id, + path: 'foo', + }) + + expect(result.ok).toBe(false) + expect(result.error!.message).toBe( + 'Cannot create a document version in a merged commit', + ) + }) +}) diff --git a/packages/core/src/services/documents/create.ts b/packages/core/src/services/documents/create.ts new file mode 100644 index 000000000..6a7827591 --- /dev/null +++ b/packages/core/src/services/documents/create.ts @@ -0,0 +1,52 @@ +import { + DocumentVersion, + documentVersions, + getDocumentsAtCommit, + Result, + Transaction, +} from '@latitude-data/core' +import { BadRequestError } from '$core/lib/errors' + +import { + assertCommitIsEditable, + existsAnotherDocumentWithSamePath, +} from './utils' + +export async function createNewDocument({ + commitId, + path, +}: { + commitId: number + path: string +}) { + const commitResult = await assertCommitIsEditable(commitId) + if (commitResult.error) return commitResult + + const currentDocuments = await getDocumentsAtCommit({ + commitId, + }) + if (currentDocuments.error) return currentDocuments + + if ( + existsAnotherDocumentWithSamePath({ + documents: currentDocuments.value, + path, + }) + ) { + return Result.error( + new BadRequestError('A document with the same path already exists'), + ) + } + + return Transaction.call(async (tx) => { + const result = await tx + .insert(documentVersions) + .values({ + commitId, + path, + }) + .returning() + const documentVersion = result[0] + return Result.ok(documentVersion!) + }) +} diff --git a/packages/core/src/services/documentVersions/index.ts b/packages/core/src/services/documents/index.ts similarity index 50% rename from packages/core/src/services/documentVersions/index.ts rename to packages/core/src/services/documents/index.ts index f756a8bb3..9c54c6bc0 100644 --- a/packages/core/src/services/documentVersions/index.ts +++ b/packages/core/src/services/documents/index.ts @@ -1 +1,2 @@ export * from './create' +export * from './modify' diff --git a/packages/core/src/services/documents/modify.test.ts b/packages/core/src/services/documents/modify.test.ts new file mode 100644 index 000000000..a67409ad3 --- /dev/null +++ b/packages/core/src/services/documents/modify.test.ts @@ -0,0 +1,131 @@ +import { listCommitChanges } from '$core/data-access' +import useTestDatabase from '$core/tests/useTestDatabase' +import { describe, expect, it } from 'vitest' + +import { mergeCommit } from '../commits/merge' +import { modifyExistingDocument } from './modify' + +useTestDatabase() + +describe('modifyExistingDocument', () => { + it('modifies a document that was created in a previous commit', async (ctx) => { + const { project } = await ctx.factories.createProject() + const { commit: commit1 } = await ctx.factories.createDraft({ project }) + const { documentVersion: doc } = await ctx.factories.createDocumentVersion({ + commit: commit1, + path: 'doc1', + content: 'Doc 1 commit 1', + }) + await mergeCommit({ commitId: commit1.id }) + + const { commit: commit2 } = await ctx.factories.createDraft({ project }) + + await modifyExistingDocument({ + commitId: commit2.id, + documentUuid: doc.documentUuid, + content: 'Doc 1 commit 2', + }).then((r) => r.unwrap()) + + const changedDocuments = await listCommitChanges({ + commitId: commit2.id, + }).then((r) => r.unwrap()) + + expect(changedDocuments.length).toBe(1) + expect(changedDocuments[0]!.path).toBe('doc1') + expect(changedDocuments[0]!.content).toBe('Doc 1 commit 2') + }) + + it('modifies a document that was created in the same commit', async (ctx) => { + const { project } = await ctx.factories.createProject() + const { commit } = await ctx.factories.createDraft({ project }) + const { documentVersion: doc } = await ctx.factories.createDocumentVersion({ + commit: commit, + path: 'doc1', + content: 'Doc 1 v1', + }) + + await modifyExistingDocument({ + commitId: commit.id, + documentUuid: doc.documentUuid, + content: 'Doc 1 v2', + }).then((r) => r.unwrap()) + + const changedDocuments = await listCommitChanges({ + commitId: commit.id, + }).then((r) => r.unwrap()) + + expect(changedDocuments.length).toBe(1) + expect(changedDocuments[0]!.path).toBe('doc1') + expect(changedDocuments[0]!.content).toBe('Doc 1 v2') + }) + + it('modifying a document creates a change to all other documents that reference it', async (ctx) => { + const { project } = await ctx.factories.createProject() + const { commit: commit1 } = await ctx.factories.createDraft({ project }) + const { documentVersion: referencedDoc } = + await ctx.factories.createDocumentVersion({ + commit: commit1, + path: 'referenced/doc', + content: 'The document that is being referenced', + }) + await ctx.factories.createDocumentVersion({ + commit: commit1, + path: 'unmodified', + content: '', + }) + await mergeCommit({ commitId: commit1.id }) + + const { commit: commit2 } = await ctx.factories.createDraft({ project }) + + await modifyExistingDocument({ + commitId: commit2.id, + documentUuid: referencedDoc.documentUuid, + content: 'The document that is being referenced v2', + }).then((r) => r.unwrap()) + + const changedDocuments = await listCommitChanges({ + commitId: commit2.id, + }).then((r) => r.unwrap()) + + expect(changedDocuments.length).toBe(2) + expect( + changedDocuments.find((d) => d.path === 'referenced/doc'), + ).toBeDefined() + expect(changedDocuments.find((d) => d.path === 'unmodified')).toBeDefined() + }) + + it('renaming a document creates a change to all other documents that reference it', async (ctx) => { + const { project } = await ctx.factories.createProject() + const { commit: commit1 } = await ctx.factories.createDraft({ project }) + const { documentVersion: referencedDoc } = + await ctx.factories.createDocumentVersion({ + commit: commit1, + path: 'referenced/doc', + content: 'The document that is being referenced', + }) + await ctx.factories.createDocumentVersion({ + commit: commit1, + path: 'unmodified', + content: '', + }) + await mergeCommit({ commitId: commit1.id }) + + const { commit: commit2 } = await ctx.factories.createDraft({ project }) + + await modifyExistingDocument({ + commitId: commit2.id, + documentUuid: referencedDoc.documentUuid, + path: 'referenced/doc2', + }).then((r) => r.unwrap()) + + const changedDocuments = await listCommitChanges({ + commitId: commit2.id, + }).then((r) => r.unwrap()) + + expect(changedDocuments.length).toBe(2) + expect( + changedDocuments.find((d) => d.path === 'referenced/doc2'), + ).toBeDefined() + expect(changedDocuments.find((d) => d.path === 'unmodified')).toBeDefined() + }) +}) diff --git a/packages/core/src/services/documents/modify.ts b/packages/core/src/services/documents/modify.ts new file mode 100644 index 000000000..7cdf2e9ed --- /dev/null +++ b/packages/core/src/services/documents/modify.ts @@ -0,0 +1,151 @@ +import { omit } from 'lodash-es' + +import { readMetadata } from '@latitude-data/compiler' +import { getDocumentsAtCommit } from '$core/data-access' +import { Result, Transaction, TypedResult } from '$core/lib' +import { BadRequestError, LatitudeError, NotFoundError } from '$core/lib/errors' +import { DocumentVersion, documentVersions } from '$core/schema' +import { eq } from 'drizzle-orm' + +import { assertCommitIsEditable } from './utils' + +async function findDocumentsWithUpdatedHash(documents: DocumentVersion[]) { + const getDocumentContent = async (path: string): Promise => { + const document = documents.find((d) => d.path === path) + if (!document) { + throw new Error(`Document not found`) + } + return document.content + } + + const updatedDocuments: DocumentVersion[] = [] + for (const document of documents) { + const { hash: newHash } = await readMetadata({ + prompt: document.content ?? '', + referenceFn: getDocumentContent, + }) + + if (newHash !== document.hash) { + updatedDocuments.push({ + ...document, + hash: newHash, + }) + } + } + + return updatedDocuments +} + +async function getUpdatedDocuments({ + currentDocuments, + updateData, +}: { + currentDocuments: DocumentVersion[] + updateData: Partial +}): Promise> { + const currentDocumentData = currentDocuments.find( + (d) => d.documentUuid === updateData.documentUuid!, + ) + if (!currentDocumentData) { + return Result.error(new NotFoundError('Document does not exist')) + } + + const newDocumentData = { ...currentDocumentData, ...updateData } + + const newDocumentsInCommit = [ + ...currentDocuments.filter( + (d) => d.documentUuid !== newDocumentData.documentUuid, + ), + newDocumentData, + ] + + const documentsWithUpdatedHash = + await findDocumentsWithUpdatedHash(newDocumentsInCommit) + + if ( + !documentsWithUpdatedHash.find( + (d) => d.documentUuid === newDocumentData.documentUuid, + ) + ) { + // The modified document may not have its hash updated, but it still needs to be added to the list of updated documents + documentsWithUpdatedHash.push(newDocumentData) + } + + return Result.ok(documentsWithUpdatedHash) +} + +export async function modifyExistingDocument({ + commitId, + documentUuid, + path, + content, + deletedAt, +}: { + commitId: number + documentUuid: string + path?: string + content?: string | null + deletedAt?: Date | null +}) { + const commitResult = await assertCommitIsEditable(commitId) + if (commitResult.error) return commitResult + + const updateData = Object.fromEntries( + Object.entries({ documentUuid, path, content, deletedAt }).filter( + ([_, v]) => v !== undefined, + ), + ) + + const currentDocuments = await getDocumentsAtCommit({ + commitId, + }) + if (currentDocuments.error) return currentDocuments + + if ( + path && + currentDocuments.value.find( + (d) => d.documentUuid !== documentUuid && d.path === path, + ) + ) { + return Result.error( + new BadRequestError('A document with the same path already exists'), + ) + } + + const documentsToUpdateResult = await getUpdatedDocuments({ + currentDocuments: currentDocuments.value, + updateData, + }) + if (documentsToUpdateResult.error) return documentsToUpdateResult + const documentsToUpdate = documentsToUpdateResult.value + + return Transaction.call(async (tx) => { + const results = await Promise.all( + documentsToUpdate.map(async (documentData) => { + const isNewDocumentVersion = documentData.commitId !== commitId + const newDocumentVersion = { + ...omit(documentData, ['id', 'commitId', 'updatedAt', 'createdAt']), + path: documentData.path, // <- This should not be necessary, but Typescript somehow is not sure that path is present. + commitId, + } + + if (isNewDocumentVersion) { + return await tx + .insert(documentVersions) + .values(newDocumentVersion) + .returning() + .then((r) => r[0]!) + } + + return await tx + .update(documentVersions) + .set(newDocumentVersion) + .where(eq(documentVersions.id, documentData.id)) + .returning() + .then((r) => r[0]!) + }), + ) + + return Result.ok(results.find((r) => r.documentUuid === documentUuid)!) + }) +} diff --git a/packages/core/src/services/documents/utils.ts b/packages/core/src/services/documents/utils.ts new file mode 100644 index 000000000..bf7c65339 --- /dev/null +++ b/packages/core/src/services/documents/utils.ts @@ -0,0 +1,31 @@ +import { + DocumentVersion, + findCommitById, + Result, + TypedResult, +} from '@latitude-data/core' +import { ForbiddenError, LatitudeError } from '$core/lib/errors' + +export async function assertCommitIsEditable( + commitId: number, +): Promise> { + const commit = await findCommitById({ id: commitId }) + + if (commit.value?.mergedAt !== null) { + return Result.error( + new ForbiddenError('Cannot create a document version in a merged commit'), + ) + } + + return Result.nil() +} + +export function existsAnotherDocumentWithSamePath({ + documents, + path, +}: { + documents: DocumentVersion[] + path: string +}) { + return documents.find((d) => d.path === path) !== undefined +} diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index f6ff54c30..ed0819516 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -1,5 +1,5 @@ export * from './users' export * from './workspaces' -export * from './documentVersions' +export * from './documents' export * from './commits' export * from './projects' diff --git a/packages/core/src/tests/factories/documents.ts b/packages/core/src/tests/factories/documents.ts index 04807e68e..ed9884de5 100644 --- a/packages/core/src/tests/factories/documents.ts +++ b/packages/core/src/tests/factories/documents.ts @@ -1,11 +1,11 @@ import { faker } from '@faker-js/faker' import type { Commit } from '$core/schema' -import { createDocumentVersion as createDocumentVersionFn } from '$core/services/documentVersions/create' +import { createNewDocument } from '$core/services/documents/create' +import { modifyExistingDocument } from '$core/services/documents/modify' export type IDocumentVersionData = { commit: Commit path?: string - documentUuid?: string content?: string } @@ -27,14 +27,19 @@ export async function createDocumentVersion( ...documentData, } - const result = await createDocumentVersionFn({ - projectId: data.commit.projectId, + let result = await createNewDocument({ + commitId: data.commit.id, path: data.path, - commitUuid: data.commit.uuid, - documentUuid: data.documentUuid, - content: data.content, }) + if (data.content) { + result = await modifyExistingDocument({ + commitId: data.commit.id, + documentUuid: result.unwrap().documentUuid, + content: data.content, + }) + } + const documentVersion = result.unwrap() return { documentVersion } } diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 3eecc0401..266764f8c 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -3,12 +3,13 @@ "compilerOptions": { "moduleResolution": "Bundler", "baseUrl": ".", - "rootDir": "./src", - "outDir": "./dist", "paths": { - "$core/*": ["./src/*"] + "@latitude-data/compiler": ["../compiler/src/*"], + "$compiler/*": ["../compiler/src/*"], + "$core/*": ["src/*"], + "acorn": ["node_modules/@latitude-data/typescript-config/types/acorn"] } }, - "include": ["src/**/*"], + "include": ["src"], "exclude": ["node_modules", "dist"] } diff --git a/packages/core/vitest.config.mjs b/packages/core/vitest.config.mjs index a7ee55fe9..9dafa0fd1 100644 --- a/packages/core/vitest.config.mjs +++ b/packages/core/vitest.config.mjs @@ -2,13 +2,17 @@ import { fileURLToPath } from 'url' import { dirname } from 'path' import { defineConfig } from 'vitest/config' -import tsconfigPaths from 'vite-tsconfig-paths' const filename = fileURLToPath(import.meta.url) const root = dirname(filename) export default defineConfig({ - plugins: [tsconfigPaths({ root })], + resolve: { + alias: { + "$compiler": `${root}/../compiler/src`, + "$core": `${root}/src`, + } + }, test: { globals: true, testTimeout: 5000, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3859f314..844493588 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,15 +203,15 @@ importers: typescript: specifier: ^5.2.2 version: 5.5.3 - vite-tsconfig-paths: - specifier: ^4.3.2 - version: 4.3.2(typescript@5.5.3) vitest: specifier: ^1.2.2 version: 1.6.0(@types/node@20.14.10) packages/core: dependencies: + '@latitude-data/compiler': + specifier: workspace:^ + version: link:../compiler '@latitude-data/env': specifier: workspace:^ version: link:../env @@ -261,9 +261,6 @@ importers: supertest: specifier: ^7.0.0 version: 7.0.0 - vite-tsconfig-paths: - specifier: ^4.3.2 - version: 4.3.2(typescript@5.5.3) vitest: specifier: ^2.0.3 version: 2.0.3(@types/node@20.14.10)