Alessandro/web 2945 comments hide body (#4385)

* chore(core): move limits logic into shared

* feat(comments): limit text and rawText for comments

* chore(core): removed test moved to shared

* chore(comments): generate gql types

* feat(comments): rework comment history limits

* chore(comments): fix tests

* chore(shared): add dayjs as dependency

---------

Co-authored-by: Gergő Jedlicska <gergo@jedlicska.com>
This commit is contained in:
Alessandro Magionami
2025-04-15 10:44:12 +02:00
committed by GitHub
parent b5ca404d00
commit 0c18acc452
29 changed files with 372 additions and 308 deletions
@@ -139,11 +139,11 @@ type Comment {
authorId: String!
archived: Boolean!
screenshot: String
text: SmartTextEditorValue!
text: SmartTextEditorValue
"""
Plain-text version of the comment text, ideal for previews
"""
rawText: String!
rawText: String
"""
Resources that this comment targets. Can be a mixture of either one stream, or multiple commits and objects.
"""
@@ -81,6 +81,7 @@ import {
getCommitsAndTheirBranchIdsFactory,
getSpecificBranchCommitsFactory
} from '@/modules/core/repositories/commits'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import {
getBranchLatestCommitsFactory,
getStreamBranchesByNameFactory
@@ -90,7 +91,15 @@ import { getStreamFactory } from '@/modules/core/repositories/streams'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import { Knex } from 'knex'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { StreamNotFoundError } from '@/modules/core/errors/stream'
import { isCreatedBeyondHistoryLimitCutoff, getProjectLimitDate } from '@speckle/shared'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { Authz } from '@speckle/shared'
const { FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
const getPersonalProjectLimits = FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
? () => Promise.resolve(Authz.PersonalProjectsLimits)
: () => Promise.resolve(null)
// We can use the main DB for these
const getStream = getStreamFactory({ db })
@@ -203,15 +212,49 @@ export = {
/**
* Format comment.text for output, since it can have multiple formats
*/
text(parent) {
const commentText = parent?.text || ''
async text(parent, _args, ctx) {
const project = await ctx.loaders.streams.getStream.load(parent.streamId)
if (!project) {
throw new StreamNotFoundError('Project not found', {
info: { streamId: parent.streamId }
})
}
const isBeyondLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: getProjectLimitDate({
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
getPersonalProjectLimits
})
})({ entity: parent, limitType: 'commentHistory', project })
// null is for out of limits
if (isBeyondLimit) return null
// why is the text nullable in the DB record?
if (!parent.text) return ''
return {
...ensureCommentSchema(commentText),
...ensureCommentSchema(parent.text),
projectId: parent.streamId
}
},
rawText(parent) {
async rawText(parent, _args, ctx) {
const project = await ctx.loaders.streams.getStream.load(parent.streamId)
if (!project) {
throw new StreamNotFoundError('Project not found', {
info: { streamId: parent.streamId }
})
}
const isBeyondLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: getProjectLimitDate({
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
getPersonalProjectLimits
})
})({ entity: parent, limitType: 'commentHistory', project })
// null is for out of limits
if (isBeyondLimit) return null
// why us the text nullable in the DB?
const { doc } = ensureCommentSchema(parent.text || '')
return documentToBasicString(doc)
},
@@ -30,7 +30,11 @@ import {
purgeNotifications
} from '@/test/notificationsHelper'
import { NotificationType } from '@/modules/notifications/helpers/types'
import { EmailSendingServiceMock, CommentsRepositoryMock } from '@/test/mocks/global'
import {
EmailSendingServiceMock,
CommentsRepositoryMock,
StreamsRepositoryMock
} from '@/test/mocks/global'
import { createAuthedTestContext, ServerAndContext } from '@/test/graphqlHelper'
import {
checkStreamResourceAccessFactory,
@@ -125,6 +129,7 @@ import {
getViewerResourcesForCommentsFactory,
getViewerResourcesFromLegacyIdentifiersFactory
} from '@/modules/core/services/commit/viewerResources'
import { StreamRecord } from '@/modules/core/helpers/types'
type LegacyCommentRecord = CommentRecord & {
total_count: string
@@ -287,6 +292,7 @@ function generateRandomCommentText() {
const mailerMock = EmailSendingServiceMock
const commentRepoMock = CommentsRepositoryMock
const streamsRepoMock = StreamsRepositoryMock
describe('Comments @comments', () => {
let app: express.Express
@@ -366,6 +372,7 @@ describe('Comments @comments', () => {
after(() => {
notificationsState.destroy()
commentRepoMock.destroy()
streamsRepoMock.destroy()
})
afterEach(() => {
@@ -1688,8 +1695,8 @@ describe('Comments @comments', () => {
expect(errors?.length || 0).to.eq(0)
expect(data?.comment).to.be.ok
expect(data?.comment?.text.doc).to.be.null
expect(data?.comment?.text.attachments?.length).to.be.greaterThan(0)
expect(data?.comment?.text?.doc).to.be.null
expect(data?.comment?.text?.attachments?.length).to.be.greaterThan(0)
})
const unexpectedValDataset = [
@@ -1698,9 +1705,18 @@ describe('Comments @comments', () => {
]
unexpectedValDataset.forEach(({ display, value }) => {
it(`unexpected text value (${display}) in DB throw sanitized errors`, async () => {
streamsRepoMock.enable()
streamsRepoMock.mockFunction('getStreamsFactory', () => async () => [
{
id: stream.id,
workspaceId: ''
} as unknown as StreamRecord
])
const item = {
id: '1',
text: value
text: value,
streamId: stream.id,
createdAt: new Date()
} as unknown as LegacyCommentRecord
commentRepoMock.enable()
@@ -1710,12 +1726,13 @@ describe('Comments @comments', () => {
totalCount: 1
}))
const { data, errors } = await readComments()
const { errors } = await readComments()
expect(data?.comments).to.not.be.ok
expect((errors || []).map((e) => e.message).join(';')).to.contain(
'Unexpected comment schema format'
)
streamsRepoMock.disable()
streamsRepoMock.resetMockedFunctions()
})
})
})
@@ -112,7 +112,7 @@ describe('Project Comments', () => {
expect(res1).to.not.haveGraphQLErrors()
expect(threadId).to.be.ok
expect(res1.data?.commentMutations.create.rawText).to.equal(parentText)
expect(res1.data?.commentMutations.create.text.doc).to.be.ok
expect(res1.data?.commentMutations.create.text?.doc).to.be.ok
expect(res1.data?.commentMutations.create.authorId).to.equal(me.id)
expect(createEventFired).to.be.true
})
@@ -161,7 +161,7 @@ describe('Project Comments', () => {
expect(res2).to.not.haveGraphQLErrors()
expect(res2.data?.commentMutations.reply.rawText).to.equal(replyText)
expect(res2.data?.commentMutations.reply.text.doc).to.be.ok
expect(res2.data?.commentMutations.reply.text?.doc).to.be.ok
expect(res2.data?.commentMutations.reply.authorId).to.equal(me.id)
expect(replyEventFired).to.be.true
})
@@ -190,7 +190,7 @@ describe('Project Comments', () => {
expect(res).to.not.haveGraphQLErrors()
expect(res.data?.commentMutations.edit.rawText).to.equal(newText)
expect(res.data?.commentMutations.edit.text.doc).to.be.ok
expect(res.data?.commentMutations.edit.text?.doc).to.be.ok
expect(res.data?.commentMutations.edit.authorId).to.equal(me.id)
expect(editEventFired).to.be.true
})
@@ -634,7 +634,7 @@ export type Comment = {
parent?: Maybe<Comment>;
permissions: CommentPermissionChecks;
/** Plain-text version of the comment text, ideal for previews */
rawText: Scalars['String']['output'];
rawText?: Maybe<Scalars['String']['output']>;
/** @deprecated Not actually implemented */
reactions?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
/** Gets the replies to this comment. */
@@ -644,7 +644,7 @@ export type Comment = {
/** Resources that this comment targets. Can be a mixture of either one stream, or multiple commits and objects. */
resources: Array<ResourceIdentifier>;
screenshot?: Maybe<Scalars['String']['output']>;
text: SmartTextEditorValue;
text?: Maybe<SmartTextEditorValue>;
/** The time this comment was last updated. Corresponds also to the latest reply to this comment, if any. */
updatedAt: Scalars['DateTime']['output'];
/** The last time you viewed this comment. Present only if an auth'ed request. Relevant only if a top level commit. */
@@ -6153,13 +6153,13 @@ export type CommentResolvers<ContextType = GraphQLContext, ParentType extends Re
id?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
parent?: Resolver<Maybe<ResolversTypes['Comment']>, ParentType, ContextType>;
permissions?: Resolver<ResolversTypes['CommentPermissionChecks'], ParentType, ContextType>;
rawText?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
rawText?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
reactions?: Resolver<Maybe<Array<Maybe<ResolversTypes['String']>>>, ParentType, ContextType>;
replies?: Resolver<ResolversTypes['CommentCollection'], ParentType, ContextType, RequireFields<CommentRepliesArgs, 'limit'>>;
replyAuthors?: Resolver<ResolversTypes['CommentReplyAuthorCollection'], ParentType, ContextType, RequireFields<CommentReplyAuthorsArgs, 'limit'>>;
resources?: Resolver<Array<ResolversTypes['ResourceIdentifier']>, ParentType, ContextType>;
screenshot?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
text?: Resolver<ResolversTypes['SmartTextEditorValue'], ParentType, ContextType>;
text?: Resolver<Maybe<ResolversTypes['SmartTextEditorValue']>, ParentType, ContextType>;
updatedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
viewedAt?: Resolver<Maybe<ResolversTypes['DateTime']>, ParentType, ContextType>;
viewerResources?: Resolver<Array<ResolversTypes['ViewerResourceItem']>, ParentType, ContextType>;
@@ -27,8 +27,10 @@ import {
batchDeleteCommitsFactory,
batchMoveCommitsFactory
} from '@/modules/core/services/commit/batchCommitActions'
import { StreamInvalidAccessError } from '@/modules/core/errors/stream'
import { isNonNullable, MaybeNullOrUndefined, Roles } from '@speckle/shared'
import {
StreamInvalidAccessError,
StreamNotFoundError
} from '@/modules/core/errors/stream'
import {
throwIfResourceAccessNotAllowed,
toProjectIdWhitelist
@@ -81,9 +83,18 @@ import { getEventBus } from '@/modules/shared/services/eventBus'
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { getDateFromLimitsFactory } from '@/modules/core/services/versions/limits'
import {
Authz,
getProjectLimitDate,
isNonNullable,
MaybeNullOrUndefined,
Roles
} from '@speckle/shared'
const { FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
const getPersonalProjectLimits = FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
? () => Promise.resolve(Authz.PersonalProjectsLimits)
: () => Promise.resolve(null)
const getStreams = getStreamsFactory({ db })
@@ -209,12 +220,10 @@ export = {
async commits(parent, args, ctx) {
const projectDB = await getProjectDbClient({ projectId: parent.id })
const limitsDate = await getDateFromLimitsFactory({
environment: {
personalProjectsLimitEnabled: FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
},
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits
})({ workspaceId: parent.workspaceId })
const limitsDate = await getProjectLimitDate({
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
getPersonalProjectLimits
})({ limitType: 'versionsHistory', project: parent })
const getCommitsByStreamId = legacyGetPaginatedStreamCommitsPageFactory({
db: projectDB,
@@ -330,13 +339,17 @@ export = {
}
}
const stream = await ctx.loaders.streams.getStream.load(parent.streamId)
const limitsDate = await getDateFromLimitsFactory({
environment: {
personalProjectsLimitEnabled: FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
},
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits
})({ workspaceId: stream?.workspaceId })
const project = await ctx.loaders.streams.getStream.load(parent.streamId)
if (!project) {
throw new StreamNotFoundError('Project not found', {
info: { streamId: parent.streamId }
})
}
const limitsDate = await getProjectLimitDate({
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
getPersonalProjectLimits
})({ limitType: 'versionsHistory', project })
const getPaginatedBranchCommits = getPaginatedBranchCommitsFactory({
getSpecificBranchCommits: getSpecificBranchCommitsFactory({ db: projectDB }),
@@ -48,14 +48,21 @@ import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
import coreModule from '@/modules/core'
import { getEventBus } from '@/modules/shared/services/eventBus'
import { StreamNotFoundError } from '@/modules/core/errors/stream'
import { getLimitedReferencedObjectFactory } from '@/modules/core/services/versions/limits'
import { throwIfResourceAccessNotAllowed } from '@/modules/core/helpers/token'
import { TokenResourceIdentifierType } from '@/modules/core/domain/tokens/types'
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { Version } from '@/modules/core/domain/commits/types'
import { GraphQLResolveInfo } from 'graphql'
import {
Authz,
getProjectLimitDate,
isCreatedBeyondHistoryLimitCutoff
} from '@speckle/shared'
const { FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } = getFeatureFlags()
const getPersonalProjectLimits = FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
? () => Promise.resolve(Authz.PersonalProjectsLimits)
: () => Promise.resolve(null)
/**
* Simple utility to check if version is inside a Model or a Project
@@ -126,12 +133,12 @@ export = {
})
}
const getLimitedReferencedObject = getLimitedReferencedObjectFactory({
environment: {
personalProjectsLimitEnabled: FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED
},
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits
})
const isBeyondLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: getProjectLimitDate({
getWorkspaceLimits: ctx.authLoaders.getWorkspaceLimits,
getPersonalProjectLimits
})
})({ entity: parent, limitType: 'versionsHistory', project })
let lastVersion: Version | null
if (getTypeFromPath(info) === 'Model') {
lastVersion = await ctx.loaders
@@ -143,10 +150,8 @@ export = {
.streams.getLastVersion.load(parent.streamId)
}
if (lastVersion?.id === parent.id) return parent.referencedObject
return await getLimitedReferencedObject({
version: parent,
workspaceId: project.workspaceId
})
if (isBeyondLimit) return null
return parent.referencedObject
}
},
Mutation: {
@@ -8,6 +8,9 @@ export function createRandomPassword(length?: number) {
return crs({ length: length ?? 10 })
}
/**
* @deprecated use the one in shared
*/
export function createRandomString(length?: number) {
return crs({ length: length ?? 10 })
}
@@ -1,68 +0,0 @@
import { Version } from '@/modules/core/domain/commits/types'
import { GetWorkspaceLimits } from '@speckle/shared/dist/commonjs/authz/domain/workspaces/operations'
import dayjs from 'dayjs'
export const PersonalProjectsLimits: {
versionHistory: { value: number; unit: 'week' }
} = {
versionHistory: {
value: 1,
unit: 'week'
}
}
export const getLimitedReferencedObjectFactory =
({
environment: { personalProjectsLimitEnabled },
getWorkspaceLimits
}: {
environment: { personalProjectsLimitEnabled: boolean }
getWorkspaceLimits: GetWorkspaceLimits
}) =>
async ({
version,
workspaceId
}: {
version: Pick<Version, 'referencedObject' | 'createdAt'>
workspaceId?: string | null
}) => {
const limitDate = await getDateFromLimitsFactory({
environment: { personalProjectsLimitEnabled },
getWorkspaceLimits
})({ workspaceId })
if (dayjs(limitDate).isAfter(version.createdAt)) return null
return version.referencedObject
}
export const getDateFromLimitsFactory =
({
getWorkspaceLimits,
environment: { personalProjectsLimitEnabled }
}: {
getWorkspaceLimits: GetWorkspaceLimits
environment: { personalProjectsLimitEnabled: boolean }
}) =>
async ({ workspaceId }: { workspaceId?: string | null }) => {
if (workspaceId) {
const limits = await getWorkspaceLimits({ workspaceId })
if (!limits?.versionsHistory) {
return null
}
return dayjs()
.subtract(limits.versionsHistory.value, limits.versionsHistory.unit)
.toDate()
}
if (!personalProjectsLimitEnabled) {
return null
}
return dayjs()
.subtract(
PersonalProjectsLimits.versionHistory.value,
PersonalProjectsLimits.versionHistory.unit
)
.toDate()
}
@@ -83,7 +83,7 @@ const createUser = createUserFactory({
emitEvent: getEventBus().emit
})
const { FF_BILLING_INTEGRATION_ENABLED, FF_WORKSPACES_MODULE_ENABLED } =
const { FF_BILLING_INTEGRATION_ENABLED, FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED } =
getFeatureFlags()
describe('Versions graphql @core', () => {
@@ -136,11 +136,11 @@ describe('Versions graphql @core', () => {
}
)
})
;(FF_WORKSPACES_MODULE_ENABLED ? describe : describe.skip)(
;(FF_FORCE_PERSONAL_PROJECTS_LIMITS_ENABLED ? describe : describe.skip)(
'Version.referencedObject',
() => {
const tenDaysAgo = dayjs().subtract(10, 'day').toDate()
it('should return version referencedObject if version is the last model version', async () => {
const tenDaysAgo = dayjs().subtract(10, 'day').toDate()
const user = await createTestUser({
name: createRandomString(),
email: createRandomEmail()
@@ -235,25 +235,14 @@ describe('Versions graphql @core', () => {
})
})
it('should return version referencedObject if version is the last project version', async () => {
const tenDaysAgo = dayjs().subtract(10, 'day').toDate()
const user = await createTestUser({
name: createRandomString(),
email: createRandomEmail()
})
const workspace = {
id: createRandomString(),
name: createRandomString(),
slug: createRandomString(),
ownerId: user.id
}
await createTestWorkspace(workspace, user, {
addPlan: { name: 'free', status: 'valid' }
})
const project1 = {
id: '',
name: createRandomString(),
workspaceId: workspace.id
name: createRandomString()
}
await createTestStream(project1, user)
@@ -1,146 +0,0 @@
import { createRandomString } from '@/modules/core/helpers/testHelpers'
import {
getDateFromLimitsFactory,
getLimitedReferencedObjectFactory
} from '@/modules/core/services/versions/limits'
import { GetWorkspaceLimits } from '@speckle/shared/dist/commonjs/authz/domain/workspaces/operations'
import { expect } from 'chai'
import dayjs from 'dayjs'
describe('Module @core', () => {
describe('Services versions', () => {
describe('getDateFromLimits', () => {
it('should return null if workspace has no versionHistory limits', async () => {
const getWorkspaceLimits = async () => null
const workspaceId = createRandomString()
expect(
await getDateFromLimitsFactory({
getWorkspaceLimits,
environment: { personalProjectsLimitEnabled: false }
})({ workspaceId })
).to.eq(null)
})
it('should return date in workspace versionHistory limits', async () => {
const getWorkspaceLimits = async () => ({
projectCount: null,
modelCount: null,
versionsHistory: { value: 1, unit: 'month' as const }
})
const workspaceId = createRandomString()
expect(
(
await getDateFromLimitsFactory({
getWorkspaceLimits,
environment: { personalProjectsLimitEnabled: false }
})({ workspaceId })
)
?.toISOString()
.slice(0, -5)
).to.eq(dayjs().subtract(1, 'month').toDate().toISOString().slice(0, -5))
})
})
describe('getLimitedReferencedObjectFactory returns a function that, ', () => {
it('should return the version referencedObject if project workspace has no limits', async () => {
const getWorkspaceLimits = (() => null) as unknown as GetWorkspaceLimits
const workspaceId = createRandomString()
const version = {
referencedObject: createRandomString(),
createdAt: new Date()
}
expect(
await getLimitedReferencedObjectFactory({
environment: { personalProjectsLimitEnabled: false },
getWorkspaceLimits
})({ version, workspaceId })
).to.eq(version.referencedObject)
})
it('should return null if version is outside of workspace limit', async () => {
const getWorkspaceLimits = (() => ({
versionsHistory: { value: 7, unit: 'day' }
})) as unknown as GetWorkspaceLimits
const workspaceId = createRandomString()
const tenDaysAgo = new Date()
tenDaysAgo.setDate(new Date().getDate() - 10)
const version = {
referencedObject: createRandomString(),
createdAt: tenDaysAgo
}
expect(
await getLimitedReferencedObjectFactory({
environment: { personalProjectsLimitEnabled: false },
getWorkspaceLimits
})({ version, workspaceId })
).to.eq(null)
})
it('should return version referencedObject if version is inside of workspace limit', async () => {
const getWorkspaceLimits = (() => ({
versionsHistory: { value: 7, unit: 'day' }
})) as unknown as GetWorkspaceLimits
const workspaceId = createRandomString()
const twoDaysAgo = new Date()
twoDaysAgo.setDate(new Date().getDate() - 2)
const version = {
referencedObject: createRandomString(),
createdAt: twoDaysAgo
}
expect(
await getLimitedReferencedObjectFactory({
environment: { personalProjectsLimitEnabled: false },
getWorkspaceLimits
})({ version, workspaceId })
).to.eq(version.referencedObject)
})
it('should return version referencedObject if project is not in a workspace and personalProjectsLimits is not enabled', async () => {
const getWorkspaceLimits = (() =>
expect.fail()) as unknown as GetWorkspaceLimits
const workspaceId = null
const tenDaysAgo = new Date()
tenDaysAgo.setDate(new Date().getDate() - 10)
const version = {
referencedObject: createRandomString(),
createdAt: tenDaysAgo
}
expect(
await getLimitedReferencedObjectFactory({
environment: { personalProjectsLimitEnabled: false },
getWorkspaceLimits
})({ version, workspaceId })
).to.eq(version.referencedObject)
})
it('should return null if project is not in a workspace and personalProjectsLimits is enabled and version is outside of limits', async () => {
const getWorkspaceLimits = (() =>
expect.fail()) as unknown as GetWorkspaceLimits
const workspaceId = null
const tenDaysAgo = new Date()
tenDaysAgo.setDate(new Date().getDate() - 10)
const version = {
referencedObject: createRandomString(),
createdAt: tenDaysAgo
}
expect(
await getLimitedReferencedObjectFactory({
environment: { personalProjectsLimitEnabled: true },
getWorkspaceLimits
})({ version, workspaceId })
).to.eq(null)
})
it('should return version referencedObject if project is not in a workspace and personalProjectsLimits is enabled and version is inside limits', async () => {
const getWorkspaceLimits = (() =>
expect.fail()) as unknown as GetWorkspaceLimits
const workspaceId = null
const version = {
referencedObject: createRandomString(),
createdAt: new Date()
}
expect(
await getLimitedReferencedObjectFactory({
environment: { personalProjectsLimitEnabled: true },
getWorkspaceLimits
})({ version, workspaceId })
).to.eq(version.referencedObject)
})
})
})
})
@@ -614,7 +614,7 @@ export type Comment = {
parent?: Maybe<Comment>;
permissions: CommentPermissionChecks;
/** Plain-text version of the comment text, ideal for previews */
rawText: Scalars['String']['output'];
rawText?: Maybe<Scalars['String']['output']>;
/** @deprecated Not actually implemented */
reactions?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
/** Gets the replies to this comment. */
@@ -624,7 +624,7 @@ export type Comment = {
/** Resources that this comment targets. Can be a mixture of either one stream, or multiple commits and objects. */
resources: Array<ResourceIdentifier>;
screenshot?: Maybe<Scalars['String']['output']>;
text: SmartTextEditorValue;
text?: Maybe<SmartTextEditorValue>;
/** The time this comment was last updated. Corresponds also to the latest reply to this comment, if any. */
updatedAt: Scalars['DateTime']['output'];
/** The last time you viewed this comment. Present only if an auth'ed request. Relevant only if a top level commit. */
@@ -5114,9 +5114,9 @@ export type CrossSyncDownloadableCommitViewerThreadsQueryVariables = Exact<{
}>;
export type CrossSyncDownloadableCommitViewerThreadsQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, commentThreads: { __typename?: 'ProjectCommentCollection', totalCount: number, totalArchivedCount: number, items: Array<{ __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } }> }, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } }> } } };
export type CrossSyncDownloadableCommitViewerThreadsQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, commentThreads: { __typename?: 'ProjectCommentCollection', totalCount: number, totalArchivedCount: number, items: Array<{ __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null }> }, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null }> } } };
export type DownloadbleCommentMetadataFragment = { __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } };
export type DownloadbleCommentMetadataFragment = { __typename?: 'Comment', id: string, viewerState?: Record<string, unknown> | null, screenshot?: string | null, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null };
export type CrossSyncProjectMetadataQueryVariables = Exact<{
id: Scalars['String']['input'];
@@ -393,13 +393,13 @@ const saveNewThreadsFactory =
const threadInputs: { originalComment: ViewerThread; input: CreateCommentInput }[] =
threads
.filter((t) => !!t.text.doc)
.filter((t) => !!t.text?.doc)
.map((t) => ({
originalComment: t,
input: {
projectId: targetStream.id,
content: {
doc: t.text.doc,
doc: t.text?.doc,
blobIds: [] // TODO: Currently not supported
},
viewerState: t.viewerState
@@ -436,12 +436,12 @@ const saveNewThreadsFactory =
)
await Promise.all(
replies.items
.filter((i) => !!i.text.doc)
.filter((i) => !!i.text?.doc)
.map((r) =>
deps.createCommentReplyAndNotify(
{
content: {
doc: r.text.doc,
doc: r.text?.doc,
blobIds: []
},
threadId: newComment.id,
@@ -615,7 +615,7 @@ export type Comment = {
parent?: Maybe<Comment>;
permissions: CommentPermissionChecks;
/** Plain-text version of the comment text, ideal for previews */
rawText: Scalars['String']['output'];
rawText?: Maybe<Scalars['String']['output']>;
/** @deprecated Not actually implemented */
reactions?: Maybe<Array<Maybe<Scalars['String']['output']>>>;
/** Gets the replies to this comment. */
@@ -625,7 +625,7 @@ export type Comment = {
/** Resources that this comment targets. Can be a mixture of either one stream, or multiple commits and objects. */
resources: Array<ResourceIdentifier>;
screenshot?: Maybe<Scalars['String']['output']>;
text: SmartTextEditorValue;
text?: Maybe<SmartTextEditorValue>;
/** The time this comment was last updated. Corresponds also to the latest reply to this comment, if any. */
updatedAt: Scalars['DateTime']['output'];
/** The last time you viewed this comment. Present only if an auth'ed request. Relevant only if a top level commit. */
@@ -5507,7 +5507,7 @@ export type AutomateValidateAuthCodeQueryVariables = Exact<{
export type AutomateValidateAuthCodeQuery = { __typename?: 'Query', automateValidateAuthCode: boolean };
export type CommentWithRepliesFragment = { __typename?: 'Comment', id: string, rawText: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null }, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } }> } };
export type CommentWithRepliesFragment = { __typename?: 'Comment', id: string, rawText?: string | null, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } | null, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } | null }> } };
export type CreateCommentMutationVariables = Exact<{
input: CommentCreateInput;
@@ -5529,7 +5529,7 @@ export type GetCommentQueryVariables = Exact<{
}>;
export type GetCommentQuery = { __typename?: 'Query', comment?: { __typename?: 'Comment', id: string, rawText: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null }, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } }> } } | null };
export type GetCommentQuery = { __typename?: 'Query', comment?: { __typename?: 'Comment', id: string, rawText?: string | null, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } | null, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } | null }> } } | null };
export type GetCommentsQueryVariables = Exact<{
streamId: Scalars['String']['input'];
@@ -5537,7 +5537,7 @@ export type GetCommentsQueryVariables = Exact<{
}>;
export type GetCommentsQuery = { __typename?: 'Query', comments?: { __typename?: 'CommentCollection', totalCount: number, cursor?: string | null, items: Array<{ __typename?: 'Comment', id: string, rawText: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null }, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } }> } }> } | null };
export type GetCommentsQuery = { __typename?: 'Query', comments?: { __typename?: 'CommentCollection', totalCount: number, cursor?: string | null, items: Array<{ __typename?: 'Comment', id: string, rawText?: string | null, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } | null, replies: { __typename?: 'CommentCollection', items: Array<{ __typename?: 'Comment', id: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null, attachments?: Array<{ __typename?: 'BlobMetadata', id: string, fileName: string, streamId: string }> | null } | null }> } }> } | null };
export type BaseCommitFieldsFragment = { __typename?: 'Commit', id: string, authorName?: string | null, authorId?: string | null, authorAvatar?: string | null, streamId?: string | null, streamName?: string | null, sourceApplication?: string | null, message?: string | null, referencedObject: string, createdAt?: string | null, commentCount: number };
@@ -5732,28 +5732,28 @@ export type UseProjectAccessRequestMutationVariables = Exact<{
export type UseProjectAccessRequestMutation = { __typename?: 'Mutation', projectMutations: { __typename?: 'ProjectMutations', accessRequestMutations: { __typename?: 'ProjectAccessRequestMutations', use: { __typename?: 'Project', id: string } } } };
export type BasicProjectCommentFragment = { __typename?: 'Comment', id: string, rawText: string, authorId: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } };
export type BasicProjectCommentFragment = { __typename?: 'Comment', id: string, rawText?: string | null, authorId: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null };
export type CreateProjectCommentMutationVariables = Exact<{
input: CreateCommentInput;
}>;
export type CreateProjectCommentMutation = { __typename?: 'Mutation', commentMutations: { __typename?: 'CommentMutations', create: { __typename?: 'Comment', id: string, rawText: string, authorId: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } } } };
export type CreateProjectCommentMutation = { __typename?: 'Mutation', commentMutations: { __typename?: 'CommentMutations', create: { __typename?: 'Comment', id: string, rawText?: string | null, authorId: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null } } };
export type CreateProjectCommentReplyMutationVariables = Exact<{
input: CreateCommentReplyInput;
}>;
export type CreateProjectCommentReplyMutation = { __typename?: 'Mutation', commentMutations: { __typename?: 'CommentMutations', reply: { __typename?: 'Comment', id: string, rawText: string, authorId: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } } } };
export type CreateProjectCommentReplyMutation = { __typename?: 'Mutation', commentMutations: { __typename?: 'CommentMutations', reply: { __typename?: 'Comment', id: string, rawText?: string | null, authorId: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null } } };
export type EditProjectCommentMutationVariables = Exact<{
input: EditCommentInput;
}>;
export type EditProjectCommentMutation = { __typename?: 'Mutation', commentMutations: { __typename?: 'CommentMutations', edit: { __typename?: 'Comment', id: string, rawText: string, authorId: string, text: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } } } };
export type EditProjectCommentMutation = { __typename?: 'Mutation', commentMutations: { __typename?: 'CommentMutations', edit: { __typename?: 'Comment', id: string, rawText?: string | null, authorId: string, text?: { __typename?: 'SmartTextEditorValue', doc?: Record<string, unknown> | null } | null } } };
export type BasicProjectFieldsFragment = { __typename?: 'Project', id: string, name: string, description?: string | null, visibility: SimpleProjectVisibility, allowPublicComments: boolean, role?: string | null, createdAt: string, updatedAt: string };
+4
View File
@@ -38,6 +38,10 @@ export const EnvHelperMock = mockRequireModule<
['@/modules/shared/index']
)
export const StreamsRepositoryMock = mockRequireModule<
typeof import('@/modules/core/repositories/streams')
>(['@/modules/core/repositories/streams'])
export const mockAdminOverride = () => {
const enable = (enabled: boolean) => {
EnvHelperMock.mockFunction('adminOverrideEnabled', () => enabled)
+1
View File
@@ -36,6 +36,7 @@
"3d"
],
"dependencies": {
"dayjs": "^1.11.13",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"nanoid": "^5.1.5",
@@ -0,0 +1,12 @@
import { HistoryLimits } from '../../../limit/domain.js'
export const PersonalProjectsLimits: HistoryLimits = {
versionsHistory: {
value: 1,
unit: 'week'
},
commentHistory: {
value: 1,
unit: 'week'
}
}
+1
View File
@@ -7,3 +7,4 @@ export {
export * from './helpers/graphql.js'
export * from './domain/authErrors.js'
export { AuthPolicyResult } from './domain/policies.js'
export { PersonalProjectsLimits } from './domain/projects/limits.js'
@@ -51,7 +51,8 @@ const buildCanMoveToWorkspace = (
return {
modelCount: 5,
projectCount: 5,
versionsHistory: null
versionsHistory: null,
commentHistory: null
}
},
getWorkspaceProjectCount: async () => {
@@ -131,7 +132,8 @@ describe('canMoveToWorkspacePolicy returns a function, that', () => {
return {
projectCount: 1,
modelCount: 5,
versionsHistory: null
versionsHistory: null,
commentHistory: null
}
},
getWorkspaceProjectCount: async () => {
@@ -54,7 +54,8 @@ const buildCanCreateModelPolicy = (
return {
modelCount: 5,
projectCount: 1,
versionsHistory: null
versionsHistory: null,
commentHistory: null
}
},
getWorkspaceModelCount: async () => {
@@ -143,7 +144,8 @@ describe('canCreateModelPolicy returns a function, that', () => {
return {
projectCount: 1,
modelCount: 5,
versionsHistory: null
versionsHistory: null,
commentHistory: null
}
},
getWorkspaceModelCount: async () => {
@@ -477,7 +477,8 @@ describe('canCreateWorkspaceProjectPolicy creates a function, that handles', ()
return {
projectCount: null,
modelCount: null,
versionsHistory: null
versionsHistory: null,
commentHistory: null
}
},
getWorkspaceProjectCount: async () => {
@@ -517,7 +518,8 @@ describe('canCreateWorkspaceProjectPolicy creates a function, that handles', ()
return {
projectCount: 10,
modelCount: 50,
versionsHistory: null
versionsHistory: null,
commentHistory: null
}
},
getWorkspaceProjectCount: async () => {
@@ -559,7 +561,8 @@ describe('canCreateWorkspaceProjectPolicy creates a function, that handles', ()
return {
projectCount: 10,
modelCount: 50,
versionsHistory: null
versionsHistory: null,
commentHistory: null
}
},
getWorkspaceProjectCount: async () => {
@@ -599,7 +602,8 @@ describe('canCreateWorkspaceProjectPolicy creates a function, that handles', ()
return {
projectCount: 10,
modelCount: 50,
versionsHistory: null
versionsHistory: null,
commentHistory: null
}
},
getWorkspaceProjectCount: async () => {
+1
View File
@@ -7,3 +7,4 @@ export * as Authz from './authz/index.js'
export * from './core/index.js'
export * from './workspaces/index.js'
export * from './onboarding/index.js'
export * from './limit/utils.js'
+13
View File
@@ -0,0 +1,13 @@
export type HistoryLimit = { value: number; unit: 'day' | 'week' | 'month' }
export type HistoryLimits = Record<HistoryLimitTypes, HistoryLimit | null>
export const HistoryLimitTypes = {
versionsHistory: 'versionsHistory',
commentHistory: 'commentHistory'
} as const
export type HistoryLimitTypes =
(typeof HistoryLimitTypes)[keyof typeof HistoryLimitTypes]
export type GetHistoryLimits = () => Promise<HistoryLimits | null>
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import {
calculateLimitCutoffDate,
getProjectLimitDate,
isCreatedBeyondHistoryLimitCutoff
} from './utils.js'
import dayjs from 'dayjs'
describe('Limits utils', () => {
describe('calculateLimitCutoffDate', () => {
it('returns null if historyLimits is null', () => {
const cutoffDate = calculateLimitCutoffDate(null, 'commentHistory')
expect(cutoffDate).toBeNull()
})
it('returns null if limitType is null on the historyLimit', () => {
const cutoffDate = calculateLimitCutoffDate(
{ commentHistory: { value: 1, unit: 'day' }, versionsHistory: null },
'versionsHistory'
)
expect(cutoffDate).toBeNull()
})
it('returns cutoff date from limits', () => {
const cutoffDate = calculateLimitCutoffDate(
{ commentHistory: { value: 1, unit: 'day' }, versionsHistory: null },
'commentHistory'
)
const now = dayjs()
expect(now.diff(cutoffDate, 'days')).to.equal(1)
})
})
describe('getProjectLimitDate', () => {
it('returns workspaceLimits for workspace projects', async () => {
const cutoffDate = await getProjectLimitDate({
getPersonalProjectLimits: () => {
expect.fail()
},
getWorkspaceLimits: async () => null
})({ limitType: 'commentHistory', project: { workspaceId: 'asdfg12345' } })
expect(cutoffDate).toBeNull()
})
it('returns projectLimits for non workspaceProjects', async () => {
const cutoffDate = await getProjectLimitDate({
getPersonalProjectLimits: async () => null,
getWorkspaceLimits: async () => {
expect.fail()
}
})({ limitType: 'commentHistory', project: { workspaceId: null } })
expect(cutoffDate).toBeNull()
})
})
describe('isCreatedBeyondHistoryLimitCutoff', () => {
it('returns false if there are no limits', async () => {
const isCreatedBeyondHistoryLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: async () => null
})({
entity: { createdAt: new Date() },
limitType: 'commentHistory',
project: { workspaceId: null }
})
expect(isCreatedBeyondHistoryLimit).to.be.toBeFalsy()
})
it('returns false if entity is newer than the limit cutoff date', async () => {
const isCreatedBeyondHistoryLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: async () => new Date(1999)
})({
entity: { createdAt: new Date() },
limitType: 'commentHistory',
project: { workspaceId: null }
})
expect(isCreatedBeyondHistoryLimit).to.be.toBeFalsy()
})
it('returns false if entity is right on the limit cutoff date', async () => {
const date = new Date()
const isCreatedBeyondHistoryLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: async () => date
})({
entity: { createdAt: date },
limitType: 'commentHistory',
project: { workspaceId: null }
})
expect(isCreatedBeyondHistoryLimit).to.be.toBeFalsy()
})
})
it('returns true of entity is older than the limit cutoff date', async () => {
const date = new Date()
const isCreatedBeyondHistoryLimit = await isCreatedBeyondHistoryLimitCutoff({
getProjectLimitDate: async () => date
})({
entity: { createdAt: new Date(1999) },
limitType: 'commentHistory',
project: { workspaceId: null }
})
expect(isCreatedBeyondHistoryLimit).to.be.toBeTruthy()
})
})
+53
View File
@@ -0,0 +1,53 @@
import dayjs from 'dayjs'
import { GetWorkspaceLimits } from '../authz/domain/workspaces/operations.js'
import { GetHistoryLimits, HistoryLimitTypes, HistoryLimits } from './domain.js'
import { Project } from '../authz/domain/projects/types.js'
export const isCreatedBeyondHistoryLimitCutoff =
({ getProjectLimitDate }: { getProjectLimitDate: GetProjectLimitDate }) =>
async ({
entity,
project,
limitType
}: {
entity: { createdAt: Date }
project: Pick<Project, 'workspaceId'>
limitType: HistoryLimitTypes
}): Promise<boolean> => {
const limitDate = await getProjectLimitDate({
project,
limitType
})
return limitDate ? dayjs(limitDate).isAfter(entity.createdAt) : false
}
export const calculateLimitCutoffDate = (
historyLimits: HistoryLimits | null,
limitType: HistoryLimitTypes
): Date | null => {
if (!historyLimits) return null
if (!historyLimits[limitType]) return null
return dayjs()
.subtract(historyLimits[limitType].value, historyLimits[limitType].unit)
.toDate()
}
type GetProjectLimitDate = (args: {
project: Pick<Project, 'workspaceId'>
limitType: HistoryLimitTypes
}) => Promise<Date | null>
export const getProjectLimitDate =
({
getWorkspaceLimits,
getPersonalProjectLimits
}: {
getWorkspaceLimits: GetWorkspaceLimits
getPersonalProjectLimits: GetHistoryLimits
}): GetProjectLimitDate =>
async ({ project, limitType }) => {
const limits = project.workspaceId
? await getWorkspaceLimits({ workspaceId: project.workspaceId })
: await getPersonalProjectLimits()
return calculateLimitCutoffDate(limits, limitType)
}
@@ -0,0 +1,5 @@
import cryptoRandomString from 'crypto-random-string'
export function createRandomString(length?: number) {
return cryptoRandomString({ length: length ?? 10 })
}
@@ -66,7 +66,8 @@ export type WorkspacePlanPriceStructure = {
const unlimited: WorkspaceLimits = {
projectCount: null,
modelCount: null,
versionsHistory: null
versionsHistory: null,
commentHistory: null
}
export type WorkspacePlanConfig<Plan extends WorkspacePlans = WorkspacePlans> = {
@@ -109,7 +110,8 @@ export const WorkspacePaidPlanConfigs: {
limits: {
projectCount: 5,
modelCount: 25,
versionsHistory: { value: 30, unit: 'day' }
versionsHistory: { value: 30, unit: 'day' },
commentHistory: { value: 30, unit: 'day' }
}
},
// New
@@ -119,7 +121,8 @@ export const WorkspacePaidPlanConfigs: {
limits: {
projectCount: null,
modelCount: null,
versionsHistory: { value: 30, unit: 'day' }
versionsHistory: { value: 30, unit: 'day' },
commentHistory: { value: 30, unit: 'day' }
}
},
[PaidWorkspacePlans.Pro]: {
@@ -133,7 +136,8 @@ export const WorkspacePaidPlanConfigs: {
limits: {
projectCount: 10,
modelCount: 50,
versionsHistory: null
versionsHistory: null,
commentHistory: null
}
},
[PaidWorkspacePlans.ProUnlimited]: {
@@ -147,7 +151,8 @@ export const WorkspacePaidPlanConfigs: {
limits: {
projectCount: null,
modelCount: null,
versionsHistory: null
versionsHistory: null,
commentHistory: null
}
}
}
@@ -203,7 +208,8 @@ export const WorkspaceUnpaidPlanConfigs: {
limits: {
projectCount: 1,
modelCount: 5,
versionsHistory: { value: 7, unit: 'day' }
versionsHistory: { value: 7, unit: 'day' },
commentHistory: { value: 7, unit: 'day' }
}
}
}
@@ -1,5 +1,6 @@
import { HistoryLimits } from '../../limit/domain.js'
export type WorkspaceLimits = {
projectCount: number | null
modelCount: number | null
versionsHistory: { value: number; unit: 'day' | 'week' | 'month' } | null
}
} & HistoryLimits
+8
View File
@@ -16986,6 +16986,7 @@ __metadata:
"@vitest/coverage-v8": "npm:^3.0.9"
"@vitest/ui": "npm:^3.0.9"
crypto-random-string: "npm:^5.0.0"
dayjs: "npm:^1.11.13"
eslint: "npm:^9.4.0"
eslint-config-prettier: "npm:^9.1.0"
knex: "npm:^2.5.1"
@@ -27572,6 +27573,13 @@ __metadata:
languageName: node
linkType: hard
"dayjs@npm:^1.11.13":
version: 1.11.13
resolution: "dayjs@npm:1.11.13"
checksum: 10/7374d63ab179b8d909a95e74790def25c8986e329ae989840bacb8b1888be116d20e1c4eee75a69ea0dfbae13172efc50ef85619d304ee7ca3c01d5878b704f5
languageName: node
linkType: hard
"dayjs@npm:^1.11.5":
version: 1.11.5
resolution: "dayjs@npm:1.11.5"