Use retention period presets instead of arbitrary values

Replace free-form retention days with a fixed set of presets
(0, 7, 14, 30, 90, 180, 365) where 0 means infinite/never delete.
This bounds the number of cron sub-tasks and simplifies the system
by removing findUniquePreferenceValues in favor of iterating over
known presets directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-03-29 17:30:46 -04:00
parent e7220c47f7
commit 5c938b0d1a
10 changed files with 69 additions and 156 deletions
-44
View File
@@ -1,5 +1,3 @@
import { TeamPreference } from "@shared/types";
import { Team } from "@server/models";
import { randomUUID } from "node:crypto";
import {
buildTeam,
@@ -50,48 +48,6 @@ describe("Team", () => {
});
});
describe("findUniquePreferenceValues", () => {
it("should return unique custom preference values", async () => {
const val1 = Math.floor(Math.random() * 10000) + 1000;
const val2 = Math.floor(Math.random() * 10000) + 11000;
const team1 = await buildTeam();
team1.setPreference(TeamPreference.TrashRetentionDays, val1);
await team1.save();
const team2 = await buildTeam();
team2.setPreference(TeamPreference.TrashRetentionDays, val2);
await team2.save();
const team3 = await buildTeam();
team3.setPreference(TeamPreference.TrashRetentionDays, val1);
await team3.save();
const results = await Team.findUniquePreferenceValues(
TeamPreference.TrashRetentionDays,
{ useCache: false }
);
expect(results).toContain(val1);
expect(results).toContain(val2);
});
it("should not return default preference values", async () => {
const results = await Team.findUniquePreferenceValues(
TeamPreference.TrashRetentionDays,
{ useCache: false }
);
const countBefore = results.length;
await buildTeam();
const resultsAfter = await Team.findUniquePreferenceValues(
TeamPreference.TrashRetentionDays,
{ useCache: false }
);
expect(resultsAfter.length).toEqual(countBefore);
});
});
describe("publicAvatarUrl", () => {
it("should return null when no avatarUrl is set", async () => {
const team = await buildTeam({ avatarUrl: null });
+1 -55
View File
@@ -3,7 +3,7 @@ import { URL } from "node:url";
import { subMinutes } from "date-fns";
import type { InferAttributes, InferCreationAttributes } from "sequelize";
import { type SaveOptions } from "sequelize";
import { Op, Sequelize } from "sequelize";
import { Op } from "sequelize";
import {
Column,
IsLowercase,
@@ -25,8 +25,6 @@ import {
IsNumeric,
} from "sequelize-typescript";
import { isEmail } from "validator";
import { Hour } from "@shared/utils/time";
import { CacheHelper } from "@server/utils/CacheHelper";
import { TeamPreferenceDefaults } from "@shared/constants";
import type { TeamPreferences } from "@shared/types";
import { TeamPreference, UserRole } from "@shared/types";
@@ -577,58 +575,6 @@ class Team extends ParanoidModel<
order: [["updatedAt", "DESC"]],
});
}
/**
* Find all unique custom values for a given preference across all teams.
*
* @param preference The preference to search for.
* @param options Options for the query.
* @returns An array of unique values for the preference.
*/
static async findUniquePreferenceValues(
preference: TeamPreference,
options: { useCache?: boolean } = {}
): Promise<number[]> {
const cacheKey = `unique_preference_values:${preference}`;
const defaultValue = TeamPreferenceDefaults[preference];
const useCache = options.useCache ?? true;
const fetcher = async () => {
const results = await this.findAll({
attributes: [
[
Sequelize.fn(
"DISTINCT",
Sequelize.literal("(preferences->> :preference)::int")
),
"value",
],
],
where: Sequelize.literal(
"preferences->> :preference IS NOT NULL AND (preferences->> :preference)::int != :defaultValue"
),
replacements: {
preference,
defaultValue,
},
raw: true,
});
return results.map((r: any) => r.value);
};
if (useCache) {
const results = await CacheHelper.getDataOrSet<number[]>(
cacheKey,
fetcher,
Hour.seconds
);
return results ?? [];
}
return (await fetcher()) ?? [];
}
}
export default Team;
@@ -22,6 +22,11 @@ export type Props = {
*/
export default class CleanupPermanentlyDeletedDocumentsByRetentionTask extends BaseTask<Props> {
public async perform({ limit, partition, retentionDays }: Props) {
// Infinite retention means documents are never permanently deleted.
if (retentionDays === 0) {
return;
}
const defaultRetentionDays = TeamPreferenceDefaults[
TeamPreference.DataRetentionDays
] as number;
@@ -43,11 +48,13 @@ export default class CleanupPermanentlyDeletedDocumentsByRetentionTask extends B
[Op.and]: [
Sequelize.literal(
isDefault
? `NOT EXISTS (
? `EXISTS (
SELECT 1 FROM teams
WHERE teams.id = "document"."teamId"
AND preferences->> :preference IS NOT NULL
AND (preferences->> :preference)::int != :retentionDays
AND (
preferences->> :preference IS NULL
OR (preferences->> :preference)::int = :retentionDays
)
)`
: `EXISTS (
SELECT 1 FROM teams
@@ -1,7 +1,5 @@
import Logger from "@server/logging/Logger";
import { Team } from "@server/models";
import { TeamPreferenceDefaults } from "@shared/constants";
import { TeamPreference } from "@shared/types";
import { RetentionPeriodPresets } from "@shared/constants";
import { Minute } from "@shared/utils/time";
import { TaskPriority } from "./base/BaseTask";
import { CronTask, TaskInterval } from "./base/CronTask";
@@ -10,24 +8,17 @@ import CleanupPermanentlyDeletedDocumentsByRetentionTask from "./CleanupPermanen
export default class CleanupPermanentlyDeletedDocumentsTask extends CronTask {
/**
* Identifies all unique retention periods and schedules a worker task for each.
* Schedules a worker task for each retention period preset.
*
* @param props Properties to be used by the task
* @param props Properties to be used by the task.
*/
public async perform(props: Props) {
const defaultRetentionDays = TeamPreferenceDefaults[
TeamPreference.DataRetentionDays
] as number;
// Find all unique custom retention periods currently in use by teams.
const customRetentionPeriods = await Team.findUniquePreferenceValues(
TeamPreference.DataRetentionDays
);
const task = new CleanupPermanentlyDeletedDocumentsByRetentionTask();
// Schedule a task for each unique custom retention period.
for (const days of customRetentionPeriods) {
for (const days of RetentionPeriodPresets) {
if (days === 0) {
continue;
}
await task.schedule({
limit: props.limit,
retentionDays: days,
@@ -35,16 +26,9 @@ export default class CleanupPermanentlyDeletedDocumentsTask extends CronTask {
});
}
// Schedule a task for the default retention period.
await task.schedule({
limit: props.limit,
retentionDays: defaultRetentionDays,
partition: props.partition,
});
Logger.debug(
"task",
`Scheduled ${customRetentionPeriods.length + 1} tranches for document cleanup`
`Scheduled ${RetentionPeriodPresets.length - 1} tranches for document cleanup`
);
}
@@ -20,6 +20,12 @@ export type Props = {
export default class ExpireDocumentsInTrashByRetentionTask extends BaseTask<Props> {
public async perform(props: Props) {
const { partition, retentionDays } = props;
// Infinite retention means documents are never expired from trash.
if (retentionDays === 0) {
return;
}
const defaultTrashRetentionDays = TeamPreferenceDefaults[
TeamPreference.TrashRetentionDays
] as number;
@@ -47,11 +53,13 @@ export default class ExpireDocumentsInTrashByRetentionTask extends BaseTask<Prop
[Op.and]: [
Sequelize.literal(
isDefault
? `NOT EXISTS (
? `EXISTS (
SELECT 1 FROM teams
WHERE teams.id = "documents"."teamId"
AND preferences->>'${TeamPreference.TrashRetentionDays}' IS NOT NULL
AND (preferences->>'${TeamPreference.TrashRetentionDays}')::int != ${defaultTrashRetentionDays}
AND (
preferences->>'${TeamPreference.TrashRetentionDays}' IS NULL
OR (preferences->>'${TeamPreference.TrashRetentionDays}')::int = ${defaultTrashRetentionDays}
)
)`
: `EXISTS (
SELECT 1 FROM teams
@@ -1,7 +1,5 @@
import Logger from "@server/logging/Logger";
import { Team } from "@server/models";
import { TeamPreferenceDefaults } from "@shared/constants";
import { TeamPreference } from "@shared/types";
import { RetentionPeriodPresets } from "@shared/constants";
import { Minute } from "@shared/utils/time";
import { TaskPriority } from "./base/BaseTask";
import { CronTask, TaskInterval } from "./base/CronTask";
@@ -10,39 +8,26 @@ import ExpireDocumentsInTrashByRetentionTask from "./ExpireDocumentsInTrashByRet
export default class ExpireDocumentsInTrashTask extends CronTask {
/**
* Identifies all unique trash retention periods and schedules a worker task for each.
* Schedules a worker task for each retention period preset.
*
* @param props Properties to be used by the task
* @param props Properties to be used by the task.
*/
public async perform(props: Props) {
const defaultTrashRetentionDays = TeamPreferenceDefaults[
TeamPreference.TrashRetentionDays
] as number;
// Find all unique custom trash retention periods currently in use by teams.
const customRetentionPeriods = await Team.findUniquePreferenceValues(
TeamPreference.TrashRetentionDays
);
const task = new ExpireDocumentsInTrashByRetentionTask();
// Schedule a task for each unique custom retention period.
for (const days of customRetentionPeriods) {
for (const days of RetentionPeriodPresets) {
if (days === 0) {
continue;
}
await task.schedule({
retentionDays: days,
partition: props.partition,
});
}
// Schedule a task for the default retention period.
await task.schedule({
retentionDays: defaultTrashRetentionDays,
partition: props.partition,
});
Logger.debug(
"task",
`Scheduled ${customRetentionPeriods.length + 1} tranches for marking documents for permanent deletion`
`Scheduled ${RetentionPeriodPresets.length - 1} tranches for marking documents for permanent deletion`
);
}
+16
View File
@@ -3,6 +3,18 @@ import { EmailDisplay, TOCPosition, UserRole } from "@shared/types";
import { TeamValidation } from "@shared/validations";
import { BaseSchema } from "@server/routes/api/schema";
const retentionDaysSchema = z
.union([
z.literal(0),
z.literal(7),
z.literal(14),
z.literal(30),
z.literal(90),
z.literal(180),
z.literal(365),
])
.optional();
export const TeamsUpdateSchema = BaseSchema.extend({
body: z.object({
/** Team name */
@@ -66,6 +78,10 @@ export const TeamsUpdateSchema = BaseSchema.extend({
emailDisplay: z.enum(EmailDisplay).optional(),
/** Whether to prevent shared documents from being embedded in iframes on external websites. */
preventDocumentEmbedding: z.boolean().optional(),
/** Days to keep documents in trash before retention phase. 0 = infinite. */
trashRetentionDays: retentionDaysSchema,
/** Days to keep documents in retention phase before permanent deletion. 0 = infinite. */
dataRetentionDays: retentionDaysSchema,
/** Whether external MCP clients can connect to the workspace. */
mcp: z.boolean().optional(),
/** List of disabled embed provider titles. */
+10 -1
View File
@@ -1,4 +1,8 @@
import type { TeamPreferences, UserPreferences } from "./types";
import type {
RetentionPeriodPreset,
TeamPreferences,
UserPreferences,
} from "./types";
import {
TOCPosition,
TeamPreference,
@@ -7,6 +11,11 @@ import {
NotificationBadgeType,
} from "./types";
/** Allowed retention period values in days. 0 means infinite (never delete). */
export const RetentionPeriodPresets: readonly RetentionPeriodPreset[] = [
0, 7, 14, 30, 90, 180, 365,
];
export const MAX_AVATAR_DISPLAY = 6;
export const Pagination = {
@@ -272,7 +272,6 @@
"New": "New",
"Only visible to you": "Only visible to you",
"Draft": "Draft",
"Template": "Template",
"Permanently deletes in {{ days }} days": "Permanently deletes in {{ days }} days",
"{{ days }} days": "{{ days }} days",
"You updated": "You updated",
+5 -2
View File
@@ -1,3 +1,6 @@
/** Allowed retention period in days. 0 means infinite (never delete). */
export type RetentionPeriodPreset = 0 | 7 | 14 | 30 | 90 | 180 | 365;
/** Available user roles. */
export enum UserRole {
Admin = "admin",
@@ -430,8 +433,8 @@ export type TeamPreferences = {
[TeamPreference.CustomTheme]?: Partial<CustomTheme>;
[TeamPreference.TocPosition]?: TOCPosition;
[TeamPreference.PreventDocumentEmbedding]?: boolean;
[TeamPreference.TrashRetentionDays]?: number;
[TeamPreference.DataRetentionDays]?: number;
[TeamPreference.TrashRetentionDays]?: RetentionPeriodPreset;
[TeamPreference.DataRetentionDays]?: RetentionPeriodPreset;
[TeamPreference.EmailDisplay]?: EmailDisplay;
[TeamPreference.MCP]?: boolean;
[TeamPreference.DisabledEmbeds]?: string[];