refactor: fix pagination with stable resolveKey, use reactive default… (#4951)

* refactor: fix pagination with stable resolveKey, use reactive defaultRoles, and remove email permission check

* Changes from call

* More changes from call

* WIP fixing team composite cursor

* paginated items fix

* minor rename

* composite cursor tools improved

* fe undoing debugging stuff

* extra fixes

* invitable collabs fix

---------

Co-authored-by: Kristaps Fabians Geikins <fabis94@live.com>
This commit is contained in:
andrewwallacespeckle
2025-06-19 09:28:31 +02:00
committed by GitHub
parent 794bd7c7e9
commit c89fe339ec
20 changed files with 228 additions and 151 deletions
@@ -128,7 +128,9 @@ export type QueryWorkspacesArgs = CountWorkspacesArgs & {
limit: number
cursor?: string
}
export type QueryWorkspaces = (args: QueryWorkspacesArgs) => Promise<Workspace[]>
export type QueryWorkspaces = (
args: QueryWorkspacesArgs
) => Promise<{ items: Workspace[]; cursor: string | null }>
export type CountWorkspaces = (args: CountWorkspacesArgs) => Promise<number>
export type GetProjectWorkspace = (args: {
projectId: string
@@ -136,10 +138,8 @@ export type GetProjectWorkspace = (args: {
/** Workspace Roles */
export type GetWorkspaceCollaboratorsArgs = {
export type GetWorkspaceCollaboratorsBaseArgs = {
workspaceId: string
limit: number
cursor?: string
filter?: {
/**
* Optionally filter by workspace role(s)
@@ -158,16 +158,17 @@ export type GetWorkspaceCollaboratorsArgs = {
hasAccessToEmail?: boolean
}
export type GetWorkspaceCollaborators = (
args: GetWorkspaceCollaboratorsArgs
) => Promise<WorkspaceTeam>
type GetWorkspaceCollaboratorsTotalCountArgs = {
workspaceId: string
export type GetWorkspaceCollaboratorsArgs = GetWorkspaceCollaboratorsBaseArgs & {
limit: number
cursor?: string
}
export type GetWorkspaceCollaborators = (
args: GetWorkspaceCollaboratorsArgs
) => Promise<{ items: WorkspaceTeam; cursor: string | null }>
export type GetWorkspaceCollaboratorsTotalCount = (
args: GetWorkspaceCollaboratorsTotalCountArgs
args: GetWorkspaceCollaboratorsBaseArgs
) => Promise<number>
type DeleteWorkspaceRoleArgs = {
@@ -250,7 +250,9 @@ export const onWorkspaceRoleDeletedFactory =
updatedByUserId: string
}) => {
// Resolve a fallback admin
const [admin] = await deps.getWorkspaceCollaborators({
const {
items: [admin]
} = await deps.getWorkspaceCollaborators({
workspaceId,
limit: 1,
filter: {
@@ -330,7 +332,9 @@ export const onWorkspaceSeatUpdatedFactory =
})
// Resolve a fallback admin
const [admin] = await deps.getWorkspaceCollaborators({
const {
items: [admin]
} = await deps.getWorkspaceCollaborators({
workspaceId,
limit: 1,
filter: {
@@ -415,7 +419,9 @@ export const onWorkspaceRoleUpdatedFactory =
if (!seatType) return
// Resolve a fallback admin
const [admin] = await deps.getWorkspaceCollaborators({
const {
items: [admin]
} = await deps.getWorkspaceCollaborators({
workspaceId,
limit: 1,
filter: {
@@ -197,8 +197,6 @@ import { getProjectFactory } from '@/modules/core/repositories/projects'
import { getProjectRegionKey } from '@/modules/multiregion/utils/regionSelector'
import { scheduleJob } from '@/modules/multiregion/services/queue'
import { updateWorkspacePlanFactory } from '@/modules/gatekeeper/services/workspacePlans'
import { GetWorkspaceCollaboratorsArgs } from '@/modules/workspaces/domain/operations'
import { WorkspaceTeamMember } from '@/modules/workspaces/domain/types'
import { UsersMeta } from '@/modules/core/dbSchema'
import { setUserActiveWorkspaceFactory } from '@/modules/workspaces/repositories/users'
import { getGenericRedis } from '@/modules/shared/redis/redis'
@@ -2086,10 +2084,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
},
LimitedWorkspace: {
team: async (parent, args) => {
const team = await getPaginatedItemsFactory<
Pick<GetWorkspaceCollaboratorsArgs, 'workspaceId' | 'limit' | 'cursor'>,
WorkspaceTeamMember
>({
const team = await getPaginatedItemsFactory({
getItems: getWorkspaceCollaboratorsFactory({ db }),
getTotalCount: getWorkspaceCollaboratorsTotalCountFactory({ db })
})({
@@ -2107,7 +2102,7 @@ export = FF_WORKSPACES_MODULE_ENABLED
roles: [Roles.Workspace.Admin]
}
})
return team
return team.items
}
},
ActiveUserMutations: {
@@ -10,6 +10,7 @@ import { metaHelpers } from '@/modules/core/helpers/meta'
import { StreamAclRecord, UserRecord } from '@/modules/core/helpers/types'
import { removePrivateFields } from '@/modules/core/helpers/userHelper'
import { formatJsonArrayRecords } from '@/modules/shared/helpers/dbHelper'
import { compositeCursorTools } from '@/modules/shared/helpers/graphqlHelper'
import { SetUserActiveWorkspace } from '@/modules/workspaces/domain/operations'
import { WorkspaceTeamMember } from '@/modules/workspaces/domain/types'
import { WorkspaceAcl as WorkspaceAclRecord } from '@/modules/workspacesCore/domain/types'
@@ -45,6 +46,12 @@ const buildInvitableCollaboratorsByProjectIdQueryFactory =
}) => {
const query = tables
.users(db)
.select([
...Users.cols,
WorkspaceAcl.groupArray('workspaceAcl'),
ServerAcl.groupArray('serverAcl'),
UserEmails.groupArray('emails')
])
.join(WorkspaceAcl.name, WorkspaceAcl.col.userId, Users.col.id)
.join(Streams.name, Streams.col.workspaceId, WorkspaceAcl.col.workspaceId)
.join(ServerAcl.name, ServerAcl.col.userId, Users.col.id)
@@ -81,25 +88,27 @@ export const getInvitableCollaboratorsByProjectIdFactory =
}
cursor?: string
limit: number
}): Promise<WorkspaceTeamMember[]> => {
}): Promise<{ items: WorkspaceTeamMember[]; cursor: string | null }> => {
const { workspaceId, projectId, search } = filter
const query = buildInvitableCollaboratorsByProjectIdQueryFactory({ db })({
workspaceId,
projectId,
search
})
if (cursor) {
query.andWhere(Users.col.createdAt, '<', cursor)
}
const { applyCursorSortAndFilter, resolveNewCursor } = compositeCursorTools({
schema: Users,
cols: ['createdAt', 'id']
})
applyCursorSortAndFilter({
query,
cursor
})
query.limit(limit)
const res = await query
.orderBy(Users.col.createdAt, 'desc')
.limit(limit)
.select([
...Users.cols,
WorkspaceAcl.groupArray('workspaceAcl'),
ServerAcl.groupArray('serverAcl'),
UserEmails.groupArray('emails')
])
const nextCursor = resolveNewCursor(res)
const formattedRes = res.map((row) => {
const workspaceAcl = formatJsonArrayRecords(
@@ -119,7 +128,7 @@ export const getInvitableCollaboratorsByProjectIdFactory =
}
})
return formattedRes
return { items: formattedRes, cursor: nextCursor }
}
export const countInvitableCollaboratorsByProjectIdFactory =
@@ -134,11 +143,16 @@ export const countInvitableCollaboratorsByProjectIdFactory =
}
}) => {
const { workspaceId, projectId, search } = filter
const query = buildInvitableCollaboratorsByProjectIdQueryFactory({ db })({
workspaceId,
projectId,
search
})
const [res] = await query.count()
const query = db
.from(
buildInvitableCollaboratorsByProjectIdQueryFactory({ db })({
workspaceId,
projectId,
search
}).as('sq1')
)
.count()
const [res] = await query
return parseInt(res?.count?.toString() ?? '0')
}
@@ -1,4 +1,5 @@
import { UserEmails } from '@/modules/core/dbSchema'
import { compositeCursorTools } from '@/modules/shared/helpers/graphqlHelper'
import {
CreateWorkspaceJoinRequest,
GetWorkspaceJoinRequest,
@@ -96,18 +97,30 @@ export const getAdminWorkspaceJoinRequestsFactory =
cursor?: string
limit: number
}) => {
const { applyCursorSortAndFilter, resolveNewCursor } = compositeCursorTools({
schema: WorkspaceJoinRequests,
cols: ['createdAt', 'userId']
})
const query = adminWorkspaceJoinRequestsBaseQueryFactory(db)(filter)
applyCursorSortAndFilter({
query,
cursor
})
if (cursor) {
query.andWhere(WorkspaceJoinRequests.col.createdAt, '<', cursor)
}
return await query
query
.select<WorkspaceJoinRequest[]>([
...WorkspaceJoinRequests.cols,
UserEmails.col.email
])
.orderBy(WorkspaceJoinRequests.col.createdAt, 'desc')
.limit(limit)
const items = await query
const newCursor = resolveNewCursor(items)
return {
items,
cursor: newCursor
}
}
export const countAdminWorkspaceJoinRequestsFactory =
@@ -138,7 +151,7 @@ const workspaceJoinRequestsBaseQueryFactory =
export const getWorkspaceJoinRequestsFactory =
({ db }: { db: Knex }) =>
({
async ({
filter,
cursor,
limit
@@ -148,14 +161,24 @@ export const getWorkspaceJoinRequestsFactory =
limit: number
}) => {
const query = workspaceJoinRequestsBaseQueryFactory(db)(filter)
const { applyCursorSortAndFilter, resolveNewCursor } = compositeCursorTools({
schema: WorkspaceJoinRequests,
cols: ['createdAt', 'userId']
})
applyCursorSortAndFilter({
query,
cursor
})
if (cursor) {
query.andWhere(WorkspaceJoinRequests.col.createdAt, '<', cursor)
query.select<WorkspaceJoinRequest[]>(WorkspaceJoinRequests.cols).limit(limit)
const items = await query
const newCursor = resolveNewCursor(items)
return {
items,
cursor: newCursor
}
return query
.select<WorkspaceJoinRequest[]>(WorkspaceJoinRequests.cols)
.orderBy(WorkspaceJoinRequests.col.createdAt, 'desc')
.limit(limit)
}
export const countWorkspaceJoinRequestsFactory =
@@ -26,6 +26,7 @@ import {
GetWorkspaceBySlug,
GetWorkspaceBySlugOrId,
GetWorkspaceCollaborators,
GetWorkspaceCollaboratorsBaseArgs,
GetWorkspaceCollaboratorsTotalCount,
GetWorkspaceCreationState,
GetWorkspaceDomains,
@@ -70,7 +71,7 @@ import {
UserEmails,
Users
} from '@/modules/core/dbSchema'
import { removePrivateFields } from '@/modules/core/helpers/userHelper'
import { removePrivateFields, UserRecord } from '@/modules/core/helpers/userHelper'
import { clamp, has, isObjectLike } from 'lodash'
import {
@@ -78,6 +79,7 @@ import {
WorkspaceTeamMember
} from '@/modules/workspaces/domain/types'
import {
compositeCursorTools,
decodeCompositeCursor,
decodeCursor,
encodeCompositeCursor,
@@ -86,6 +88,7 @@ import {
import { adminOverrideEnabled } from '@/modules/shared/helpers/envHelper'
const tables = {
users: (db: Knex) => db<UserRecord>(Users.name),
branches: (db: Knex) => db<BranchRecord>('branches'),
streams: (db: Knex) => db<StreamRecord>('streams'),
streamAcl: (db: Knex) => db<StreamAclRecord>('stream_acl'),
@@ -311,15 +314,24 @@ const buildWorkspacesQuery = ({ db, search }: { db: Knex; search?: string }) =>
export const queryWorkspacesFactory =
({ db }: { db: Knex }): QueryWorkspaces =>
async ({ limit, cursor, filter }) => {
const { applyCursorSortAndFilter, resolveNewCursor } = compositeCursorTools({
schema: Workspaces,
cols: ['createdAt', 'id']
})
const query = buildWorkspacesQuery({ db, search: filter?.search })
.select()
.orderBy('createdAt', 'desc')
.limit(limit)
if (cursor) {
query.andWhere('createdAt', '<', cursor)
}
return await query
applyCursorSortAndFilter({
query,
cursor
})
const res = await query
const newCursor = resolveNewCursor(res)
return { items: res, cursor: newCursor }
}
export const countWorkspacesFactory =
@@ -445,19 +457,12 @@ export const upsertWorkspaceRoleFactory =
.merge(['role'])
}
export const getWorkspaceCollaboratorsTotalCountFactory =
({ db }: { db: Knex }): GetWorkspaceCollaboratorsTotalCount =>
async ({ workspaceId }) => {
const [res] = await DbWorkspaceAcl.knex(db).where({ workspaceId }).count()
const count = parseInt(res.count)
return count || 0
}
const getWorkspaceCollaboratorsBaseQuery =
(deps: { db: Knex }) => (params: GetWorkspaceCollaboratorsBaseArgs) => {
const { workspaceId, filter = {} } = params
export const getWorkspaceCollaboratorsFactory =
({ db }: { db: Knex }): GetWorkspaceCollaborators =>
async ({ workspaceId, filter = {}, cursor, limit = 25, hasAccessToEmail }) => {
const query = db
.from(Users.name)
const query = tables
.users(deps.db)
.select<Array<WorkspaceTeamMember & { workspaceRoleCreatedAt: Date }>>(
...Users.cols,
ServerAcl.col.role,
@@ -474,7 +479,6 @@ export const getWorkspaceCollaboratorsFactory =
// it will not be surfaced by this query
//
.andWhere(UserEmails.col.primary, true)
.orderBy('workspaceRoleCreatedAt', 'desc')
const { search, roles, seatType, excludeUserIds } = filter || {}
@@ -505,9 +509,40 @@ export const getWorkspaceCollaboratorsFactory =
})
}
if (cursor) {
query.andWhere(DbWorkspaceAcl.col.createdAt, '<', cursor)
}
return query
}
export const getWorkspaceCollaboratorsTotalCountFactory =
({ db }: { db: Knex }): GetWorkspaceCollaboratorsTotalCount =>
async (params) => {
const q = db
.from(getWorkspaceCollaboratorsBaseQuery({ db })(params).as('t1'))
.count()
const [res] = await q
const count = parseInt(res.count + '')
return count || 0
}
export const getWorkspaceCollaboratorsFactory =
({ db }: { db: Knex }): GetWorkspaceCollaborators =>
async (params) => {
const { limit = 25, hasAccessToEmail } = params
const query = getWorkspaceCollaboratorsBaseQuery({ db })(params)
const { applyCursorSortAndFilter, resolveNewCursor } = compositeCursorTools({
schema: {
col: {
workspaceRoleCreatedAt: DbWorkspaceAcl.col.createdAt,
id: Users.col.id
}
},
cols: ['workspaceRoleCreatedAt', 'id']
})
applyCursorSortAndFilter({
query,
cursor: params.cursor
})
if (limit) {
query.limit(clamp(limit, 0, 100))
@@ -521,8 +556,9 @@ export const getWorkspaceCollaboratorsFactory =
workspaceId: i.workspaceId,
role: i.role
}))
const newCursor = resolveNewCursor(items)
return items
return { items, cursor: newCursor }
}
export const storeWorkspaceDomainFactory =
@@ -77,7 +77,7 @@ export const sendWorkspaceJoinRequestReceivedEmailFactory =
}): SendWorkspaceJoinRequestReceivedEmail =>
async (args) => {
const { requester, workspace } = args
const [serverInfo, workspaceAdmins] = await Promise.all([
const [serverInfo, { items: workspaceAdmins }] = await Promise.all([
getServerInfo(),
getWorkspaceCollaborators({
workspaceId: workspace.id,
@@ -90,7 +90,9 @@ export const requestToJoinWorkspaceFactory =
}
if (workspace.discoverabilityAutoJoinEnabled) {
const [workspaceAdmin] = await getWorkspaceTeam({
const {
items: [workspaceAdmin]
} = await getWorkspaceTeam({
workspaceId,
limit: 1,
filter: {
@@ -250,7 +250,7 @@ describe('Workspace repositories', () => {
})
it('returns all workspace members', async () => {
const team = await getWorkspaceCollaborators({
const { items: team } = await getWorkspaceCollaborators({
workspaceId: testWorkspace.id,
limit: 50
})
@@ -298,7 +298,7 @@ describe('Workspace repositories', () => {
})
it('limits search results to specified workspace', async () => {
const result = await getWorkspaceCollaborators({
const { items: result } = await getWorkspaceCollaborators({
workspaceId: testWorkspaces[2].id,
limit: 50,
filter: { search: 'John' }
@@ -86,7 +86,7 @@ describe('Workspace repositories', () => {
})
it('should return all workspace collaborators not members of the project', async () => {
const invitable = await getInvitableCollaboratorsByProjectId({
const { items: invitable } = await getInvitableCollaboratorsByProjectId({
filter: {
workspaceId: testWorkspace.id,
projectId: testProject.id
@@ -100,7 +100,7 @@ describe('Workspace repositories', () => {
])
})
it('should should filter by user name', async () => {
const invitable = await getInvitableCollaboratorsByProjectId({
const { items: invitable } = await getInvitableCollaboratorsByProjectId({
filter: {
workspaceId: testWorkspace.id,
projectId: testProject.id,
@@ -114,7 +114,7 @@ describe('Workspace repositories', () => {
])
})
it('should should filter by user email', async () => {
const invitable = await getInvitableCollaboratorsByProjectId({
const { items: invitable } = await getInvitableCollaboratorsByProjectId({
filter: {
workspaceId: testWorkspace.id,
projectId: testProject.id,
@@ -128,7 +128,7 @@ describe('Workspace repositories', () => {
])
})
it('should should filter by user name and email', async () => {
const invitable = await getInvitableCollaboratorsByProjectId({
const { items: invitable } = await getInvitableCollaboratorsByProjectId({
filter: {
workspaceId: testWorkspace.id,
projectId: testProject.id,
@@ -133,7 +133,7 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
getWorkspaceWithDomains: async () => null,
getUserEmails: async () => [],
addOrUpdateWorkspaceRole: async () => {},
getWorkspaceTeam: async () => []
getWorkspaceTeam: async () => ({ items: [], cursor: null })
})({ workspaceId: createRandomString(), userId: createRandomString() })
)
@@ -150,7 +150,7 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
getWorkspaceWithDomains: async () => null,
getUserEmails: async () => [],
addOrUpdateWorkspaceRole: async () => {},
getWorkspaceTeam: async () => []
getWorkspaceTeam: async () => ({ items: [], cursor: null })
})({ workspaceId: createRandomString(), userId: createRandomString() })
)
@@ -185,7 +185,7 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
workspace as unknown as WorkspaceWithDomains,
getUserEmails: async () => [],
addOrUpdateWorkspaceRole: async () => {},
getWorkspaceTeam: async () => []
getWorkspaceTeam: async () => ({ items: [], cursor: null })
})({ workspaceId: createRandomString(), userId: createRandomString() })
)
@@ -243,7 +243,7 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
getUserEmails: async () =>
[{ email: user.email, verified: true }] as unknown as UserEmail[],
addOrUpdateWorkspaceRole: async () => {},
getWorkspaceTeam: async () => []
getWorkspaceTeam: async () => ({ items: [], cursor: null })
})({ workspaceId: workspace.id, userId: user.id })
).to.equal(true)
@@ -314,7 +314,7 @@ const { FF_WORKSPACES_MODULE_ENABLED } = getFeatureFlags()
getUserEmails: async () =>
[{ email: user.email, verified: true }] as unknown as UserEmail[],
addOrUpdateWorkspaceRole: async () => {},
getWorkspaceTeam: async () => []
getWorkspaceTeam: async () => ({ items: [], cursor: null })
})
expect(
@@ -463,6 +463,11 @@ describe('Workspaces GQL CRUD', () => {
limit: 10,
cursor: resA.data?.workspace.team.cursor
})
const resC = await largeWorkspaceApollo.execute(GetWorkspaceTeamDocument, {
workspaceId: largeWorkspace.id,
limit: 10,
cursor: resB.data?.workspace.team.cursor
})
expect(resA).to.not.haveGraphQLErrors()
expect(resA.data?.workspace.team.items.length).to.equal(2)
@@ -475,7 +480,11 @@ describe('Workspaces GQL CRUD', () => {
expect(resB).to.not.haveGraphQLErrors()
expect(resB.data?.workspace.team.items.length).to.equal(4)
expect(resB.data?.workspace.team.cursor).to.be.null
expect(resB.data?.workspace.team.cursor).to.be.not.null
expect(resC).to.not.haveGraphQLErrors()
expect(resC.data?.workspace.team.items.length).to.equal(0)
expect(resC.data?.workspace.team.cursor).to.be.null
})
it('should return correct total count', async () => {