add empty trash api to prune soft deleted items in trash

This commit is contained in:
Hongbo Wu
2024-01-02 14:08:41 +08:00
parent 5eb19cda71
commit d31c520a90
7 changed files with 133 additions and 8 deletions

View File

@ -727,6 +727,22 @@ export type DeviceTokensSuccess = {
deviceTokens: Array<DeviceToken>;
};
export type EmptyTrashError = {
__typename?: 'EmptyTrashError';
errorCodes: Array<EmptyTrashErrorCode>;
};
export enum EmptyTrashErrorCode {
Unauthorized = 'UNAUTHORIZED'
}
export type EmptyTrashResult = EmptyTrashError | EmptyTrashSuccess;
export type EmptyTrashSuccess = {
__typename?: 'EmptyTrashSuccess';
success?: Maybe<Scalars['Boolean']>;
};
export type Feature = {
__typename?: 'Feature';
createdAt: Scalars['Date'];
@ -1352,6 +1368,7 @@ export type Mutation = {
deleteNewsletterEmail: DeleteNewsletterEmailResult;
deleteRule: DeleteRuleResult;
deleteWebhook: DeleteWebhookResult;
emptyTrash: EmptyTrashResult;
fetchContent: FetchContentResult;
generateApiKey: GenerateApiKeyResult;
googleLogin: LoginResult;
@ -3663,6 +3680,10 @@ export type ResolversTypes = {
DeviceTokensErrorCode: DeviceTokensErrorCode;
DeviceTokensResult: ResolversTypes['DeviceTokensError'] | ResolversTypes['DeviceTokensSuccess'];
DeviceTokensSuccess: ResolverTypeWrapper<DeviceTokensSuccess>;
EmptyTrashError: ResolverTypeWrapper<EmptyTrashError>;
EmptyTrashErrorCode: EmptyTrashErrorCode;
EmptyTrashResult: ResolversTypes['EmptyTrashError'] | ResolversTypes['EmptyTrashSuccess'];
EmptyTrashSuccess: ResolverTypeWrapper<EmptyTrashSuccess>;
Feature: ResolverTypeWrapper<Feature>;
Feed: ResolverTypeWrapper<Feed>;
FeedArticle: ResolverTypeWrapper<FeedArticle>;
@ -4169,6 +4190,9 @@ export type ResolversParentTypes = {
DeviceTokensError: DeviceTokensError;
DeviceTokensResult: ResolversParentTypes['DeviceTokensError'] | ResolversParentTypes['DeviceTokensSuccess'];
DeviceTokensSuccess: DeviceTokensSuccess;
EmptyTrashError: EmptyTrashError;
EmptyTrashResult: ResolversParentTypes['EmptyTrashError'] | ResolversParentTypes['EmptyTrashSuccess'];
EmptyTrashSuccess: EmptyTrashSuccess;
Feature: Feature;
Feed: Feed;
FeedArticle: FeedArticle;
@ -4975,6 +4999,20 @@ export type DeviceTokensSuccessResolvers<ContextType = ResolverContext, ParentTy
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type EmptyTrashErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['EmptyTrashError'] = ResolversParentTypes['EmptyTrashError']> = {
errorCodes?: Resolver<Array<ResolversTypes['EmptyTrashErrorCode']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type EmptyTrashResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['EmptyTrashResult'] = ResolversParentTypes['EmptyTrashResult']> = {
__resolveType: TypeResolveFn<'EmptyTrashError' | 'EmptyTrashSuccess', ParentType, ContextType>;
};
export type EmptyTrashSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['EmptyTrashSuccess'] = ResolversParentTypes['EmptyTrashSuccess']> = {
success?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type FeatureResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Feature'] = ResolversParentTypes['Feature']> = {
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
expiresAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
@ -5460,6 +5498,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
deleteNewsletterEmail?: Resolver<ResolversTypes['DeleteNewsletterEmailResult'], ParentType, ContextType, RequireFields<MutationDeleteNewsletterEmailArgs, 'newsletterEmailId'>>;
deleteRule?: Resolver<ResolversTypes['DeleteRuleResult'], ParentType, ContextType, RequireFields<MutationDeleteRuleArgs, 'id'>>;
deleteWebhook?: Resolver<ResolversTypes['DeleteWebhookResult'], ParentType, ContextType, RequireFields<MutationDeleteWebhookArgs, 'id'>>;
emptyTrash?: Resolver<ResolversTypes['EmptyTrashResult'], ParentType, ContextType>;
fetchContent?: Resolver<ResolversTypes['FetchContentResult'], ParentType, ContextType, RequireFields<MutationFetchContentArgs, 'id'>>;
generateApiKey?: Resolver<ResolversTypes['GenerateApiKeyResult'], ParentType, ContextType, RequireFields<MutationGenerateApiKeyArgs, 'input'>>;
googleLogin?: Resolver<ResolversTypes['LoginResult'], ParentType, ContextType, RequireFields<MutationGoogleLoginArgs, 'input'>>;
@ -6647,6 +6686,9 @@ export type Resolvers<ContextType = ResolverContext> = {
DeviceTokensError?: DeviceTokensErrorResolvers<ContextType>;
DeviceTokensResult?: DeviceTokensResultResolvers<ContextType>;
DeviceTokensSuccess?: DeviceTokensSuccessResolvers<ContextType>;
EmptyTrashError?: EmptyTrashErrorResolvers<ContextType>;
EmptyTrashResult?: EmptyTrashResultResolvers<ContextType>;
EmptyTrashSuccess?: EmptyTrashSuccessResolvers<ContextType>;
Feature?: FeatureResolvers<ContextType>;
Feed?: FeedResolvers<ContextType>;
FeedArticle?: FeedArticleResolvers<ContextType>;

View File

@ -644,6 +644,20 @@ type DeviceTokensSuccess {
deviceTokens: [DeviceToken!]!
}
type EmptyTrashError {
errorCodes: [EmptyTrashErrorCode!]!
}
enum EmptyTrashErrorCode {
UNAUTHORIZED
}
union EmptyTrashResult = EmptyTrashError | EmptyTrashSuccess
type EmptyTrashSuccess {
success: Boolean
}
type Feature {
createdAt: Date!
expiresAt: Date
@ -1213,6 +1227,7 @@ type Mutation {
deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult!
deleteRule(id: ID!): DeleteRuleResult!
deleteWebhook(id: ID!): DeleteWebhookResult!
emptyTrash: EmptyTrashResult!
fetchContent(id: ID!): FetchContentResult!
generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult!
googleLogin(input: GoogleLoginInput!): LoginResult!

View File

@ -21,6 +21,8 @@ import {
CreateArticleError,
CreateArticleErrorCode,
CreateArticleSuccess,
EmptyTrashError,
EmptyTrashSuccess,
FetchContentError,
FetchContentErrorCode,
FetchContentSuccess,
@ -71,6 +73,7 @@ import {
findOrCreateLabels,
} from '../../services/labels'
import {
batchDelete,
batchUpdateLibraryItems,
createLibraryItem,
findLibraryItemById,
@ -1035,6 +1038,27 @@ export const fetchContentResolver = authorized<
}
})
export const emptyTrashResolver = authorized<
EmptyTrashSuccess,
EmptyTrashError
>(async (_, __, { uid }) => {
analytics.track({
userId: uid,
event: 'empty_trash',
})
await batchDelete({
state: LibraryItemState.Deleted,
user: {
id: uid,
},
})
return {
success: true,
}
})
const getUpdateReason = (libraryItem: LibraryItem, since: Date) => {
if (libraryItem.deletedAt) {
return UpdateReason.Deleted

View File

@ -5,7 +5,7 @@ import express from 'express'
import { LessThan } from 'typeorm'
import { StatusType } from '../../entity/user'
import { readPushSubscription } from '../../pubsub'
import { batchDeleteUsers } from '../../services/user'
import { batchDelete } from '../../services/user'
import { corsConfig } from '../../utils/corsConfig'
import { logger } from '../../utils/logger'
@ -52,7 +52,7 @@ export function userServiceRouter() {
const subTime = cleanupMessage.subDays * 1000 * 60 * 60 * 24 // convert days to milliseconds
try {
const result = await batchDeleteUsers({
const result = await batchDelete({
status: StatusType.Deleted,
updatedAt: LessThan(new Date(Date.now() - subTime)), // subDays ago
})

View File

@ -2761,6 +2761,20 @@ const schema = gql`
BAD_REQUEST
}
union EmptyTrashResult = EmptyTrashSuccess | EmptyTrashError
type EmptyTrashSuccess {
success: Boolean
}
type EmptyTrashError {
errorCodes: [EmptyTrashErrorCode!]!
}
enum EmptyTrashErrorCode {
UNAUTHORIZED
}
# Mutations
type Mutation {
googleLogin(input: GoogleLoginInput!): LoginResult!
@ -2872,6 +2886,7 @@ const schema = gql`
updateNewsletterEmail(
input: UpdateNewsletterEmailInput!
): UpdateNewsletterEmailResult!
emptyTrash: EmptyTrashResult!
}
# FIXME: remove sort from feedArticles after all cached tabs are closed

View File

@ -1073,3 +1073,30 @@ export const deleteLibraryItemsByAdmin = async (
'admin'
)
}
export const batchDelete = async (criteria: FindOptionsWhere<LibraryItem>) => {
const batchSize = 1000
const qb = libraryItemRepository.createQueryBuilder().where(criteria)
const countSql = queryBuilderToRawSql(qb.select('COUNT(1)'))
const subQuery = queryBuilderToRawSql(qb.select('id').limit(batchSize))
const sql = `
-- Set batch size
DO $$
DECLARE
batch_size INT := ${batchSize};
BEGIN
-- Loop through batches
FOR i IN 0..CEIL((${countSql})) * 1.0 / batch_size) - 1 LOOP
-- Delete batch
DELETE FROM omnivore.library_item
WHERE id = ANY(
${subQuery}
);
END LOOP;
END $$
`
return authTrx(async (t) => t.query(sql))
}

View File

@ -48,13 +48,12 @@ export const createUsers = async (users: DeepPartial<User>[]) => {
)
}
export const batchDeleteUsers = async (criteria: FindOptionsWhere<User>) => {
export const batchDelete = async (criteria: FindOptionsWhere<User>) => {
const userQb = getRepository(User).createQueryBuilder().where(criteria)
const userCountSql = queryBuilderToRawSql(userQb.select('COUNT(1)'))
const userSubQuery = queryBuilderToRawSql(userQb.select('id INTO user_ids'))
const batchSize = 1000
const start = new Date().toISOString()
const sql = `
-- Set batch size
DO $$
@ -65,15 +64,18 @@ export const batchDeleteUsers = async (criteria: FindOptionsWhere<User>) => {
-- Loop through batches of users
FOR i IN 0..CEIL((${userCountSql})) * 1.0 / batch_size) - 1 LOOP
-- GET batch of user ids
${userSubQuery} LIMIT ${batchSize} OFFSET i * batch_size;
${userSubQuery} LIMIT batch_size OFFSET i * batch_size;
-- Loop through batches of items
FOR j IN 0..CEIL((SELECT COUNT(1) FROM omnivore.library_item WHERE user_id = ANY(user_ids))) * 1.0 / batch_size) - 1 LOOP
-- Delete batch of items
DELETE FROM omnivore.library_item
WHERE user_id = ANY(user_ids)
AND updated_at < '${start}'
LIMIT ${batchSize};
WHERE id = ANY(
SELECT id
FROM omnivore.library_item
WHERE user_id = ANY(user_ids)
LIMIT batch_size
);
END LOOP;
-- Delete the batch of users