diff --git a/README.md b/README.md index 0fe8467..f171dd1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,104 @@ -# AdminForth Auto Remove Plugin +# Auto Remove Plugin -Allows to remove old records. +This plugin removes records from resources based on **count-based** or **time-based** rules. +It is designed for cleaning up: + +* old records +* logs +* demo/test data +* temporary entities + +--- + +## Installation + +To install the plugin: + +```ts +npm install @adminforth/auto-remove +``` + +Import it into your resource: +```ts +import AutoRemovePlugin from '../../plugins/adminforth-auto-remove/index.js'; +``` + +## Plugin Options + +```ts +export interface PluginOptions { + createdAtField: string; + + /** + * - count-based: Delete items > keepAtLeast + * - time-based: Delete age > deleteOlderThan + */ + mode: AutoRemoveMode; + + /** + * for count-based mode (100', '1k', '10k', '1m') + */ + keepAtLeast?: HumanNumber; + + /** + * Minimum number of items to always keep in count-based mode. + * This acts as a safety threshold together with `keepAtLeast`. + * Example formats: '100', '1k', '10k', '1m'. + * + * Validation ensures that minItemsKeep <= keepAtLeast. + */ + minItemsKeep?: HumanNumber; + + /** + * Max age of item for time-based mode ('1d', '7d', '1mon', '1y') + */ + deleteOlderThan?: HumanDuration; + + /** + * Interval for running cleanup (e.g. '1h', '1d') + * Default '1d' + */ + interval?: HumanDuration; +} +``` +--- + +## Usage +To use the plugin, add it to your resource file. Here's an example: + +for count-based mode +```ts +new AutoRemovePlugin({ + createdAtField: 'created_at', + mode: 'count-based', + keepAtLeast: '200', + interval: '1s', + minItemsKeep: '180', + }), +``` + +for time-based mode +```ts +new AutoRemovePlugin({ + createdAtField: 'created_at', + mode: 'time-based', + deleteOlderThan: '3min', + interval: '5s', + }), +``` + +--- + +## Result +After running **AutoRemovePlugin**, old or excess records are deleted automatically: + +- **Count-based mode:** keeps the newest `keepAtLeast` records, deletes older ones. + Example: `keepAtLeast = 500` → table with 650 records deletes 150 oldest. + +- **Time-based mode:** deletes records older than `deleteOlderThan`. + Example: `deleteOlderThan = '7d'` → removes records older than 7 days. + +- **Manual cleanup:** `POST /plugin/{pluginInstanceId}/cleanup`, returns `{ "ok": true }`. + +Logs show how many records were removed per run. \ No newline at end of file diff --git a/index.ts b/index.ts index 07b0dee..8126390 100644 --- a/index.ts +++ b/index.ts @@ -3,16 +3,13 @@ import type { IAdminForth, IHttpServer, AdminForthResource } from "adminforth"; import type { PluginOptions } from './types.js'; import { parseHumanNumber } from './utils/parseNumber.js'; import { parseDuration } from './utils/parseDuration.js'; -// Why do we need MAX_DELETE_PER_RUN? -const MAX_DELETE_PER_RUN = 500; + +const ITEMS_PER_DELETE = 100; export default class AutoRemovePlugin extends AdminForthPlugin { options: PluginOptions; - // I don't understand why do you need this resource config if you alredy have it below - // You can use create resource: AdminForthResourc and somewhere below just set it - // Then you will remove [this._resourceConfig.columns.find(c => c.primaryKey)!.name] and will use just resource - protected _resourceConfig!: AdminForthResource; - private timer?: NodeJS.Timeout; + resource?: AdminForthResource; + timer?: NodeJS.Timeout; constructor(options: PluginOptions) { super(options, import.meta.url); @@ -26,9 +23,11 @@ export default class AutoRemovePlugin extends AdminForthPlugin { async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) { super.modifyResourceConfig(adminforth, resourceConfig); - this._resourceConfig = resourceConfig; - // Start the cleanup timer + if (resourceConfig) { + this.resource = resourceConfig; + } + const intervalMs = parseDuration(this.options.interval || '1d'); this.timer = setInterval(() => { this.runCleanup(adminforth).catch(console.error); @@ -36,20 +35,25 @@ export default class AutoRemovePlugin extends AdminForthPlugin { } validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) { - // Check createdAtField exists and is date/datetim const col = resourceConfig.columns.find(c => c.name === this.options.createdAtField); - // I don't like error messages look at other plugins and change to something similar - if (!col) throw new Error(`createdAtField "${this.options.createdAtField}" not found`); + if (!col) throw new Error(`Field "${this.options.createdAtField}" not found in resource "${resourceConfig.label}"`); if (![AdminForthDataTypes.DATE, AdminForthDataTypes.DATETIME].includes(col.type!)) { - throw new Error(`createdAtField must be date/datetime/timestamp`); + throw new Error(`Field "${this.options.createdAtField}" in resource "${resourceConfig.label}" must be of type DATE or DATETIME`); } // Check mode-specific options - if (this.options.mode === 'count-based' && !this.options.maxItems) { - throw new Error('maxItems is required for count-based mode'); + if (this.options.mode === 'count-based') { + if (!this.options.keepAtLeast) { + throw new Error('keepAtLeast is required for count-based mode'); + } + if (this.options.minItemsKeep && parseHumanNumber(this.options.minItemsKeep) > parseHumanNumber(this.options.keepAtLeast)) { + throw new Error( + `Option "minItemsKeep" (${this.options.minItemsKeep}) cannot be greater than "keepAtLeast" (${this.options.keepAtLeast}). Please set "minItemsKeep" less than or equal to "keepAtLeast"` + ); + } } - if (this.options.mode === 'time-based' && !this.options.maxAge) { - throw new Error('maxAge is required for time-based mode'); + if (this.options.mode === 'time-based' && !this.options.deleteOlderThan) { + throw new Error('deleteOlderThan is required for time-based mode'); } } @@ -64,35 +68,44 @@ export default class AutoRemovePlugin extends AdminForthPlugin { console.error('AutoRemovePlugin runCleanup error:', err); } } - + private async cleanupByCount(adminforth: IAdminForth) { - const limit = parseHumanNumber(this.options.maxItems!); - const resource = adminforth.resource(this._resourceConfig.resourceId); + const limit = parseHumanNumber(this.options.keepAtLeast!); + const resource = adminforth.resource(this.resource.resourceId); const allRecords = await resource.list([], null, null, [Sorts.ASC(this.options.createdAtField)]); if (allRecords.length <= limit) return; - const toDelete = allRecords.slice(0, allRecords.length - limit).slice(0, this.options.maxDeletePerRun || MAX_DELETE_PER_RUN); - for (const r of toDelete) { - await resource.delete(r[this._resourceConfig.columns.find(c => c.primaryKey)!.name]); - console.log(`AutoRemovePlugin: deleted record ${r[this._resourceConfig.columns.find(c => c.primaryKey)!.name]} due to count-based limit`); + const toDelete = allRecords.slice(0, allRecords.length - limit); + + const pkColumn = this.resource.columns.find(c => c.primaryKey)!.name; + + for (let i = 0; i < toDelete.length; i += ITEMS_PER_DELETE) { + const deletePackage = toDelete.slice(i, i + ITEMS_PER_DELETE); + await Promise.all(deletePackage.map(r => resource.delete(r[pkColumn]))); } + + console.log(`AutoRemovePlugin: deleted ${toDelete.length} records due to count-based limit`); } private async cleanupByTime(adminforth: IAdminForth) { - const maxAgeMs = parseDuration(this.options.maxAge!); + const maxAgeMs = parseDuration(this.options.deleteOlderThan!); const threshold = Date.now() - maxAgeMs; - const resource = adminforth.resource(this._resourceConfig.resourceId); + const resource = adminforth.resource(this.resource.resourceId); + + const allRecords = await resource.list([], null, null, [Sorts.ASC(this.options.createdAtField)]); + const toDelete = allRecords.filter(r => new Date(r[this.options.createdAtField]).getTime() < threshold); + + const pkColumn = this.resource.columns.find(c => c.primaryKey)!.name; - const allRecords = await resource.list([], null, null, Sorts.ASC(this.options.createdAtField)); - const toDelete = allRecords - .filter(r => new Date(r[this.options.createdAtField]).getTime() < threshold) - .slice(0, this.options.maxDeletePerRun || MAX_DELETE_PER_RUN); + for (let i = 0; i < toDelete.length; i += ITEMS_PER_DELETE) { + const deletePackage = toDelete.slice(i, i + ITEMS_PER_DELETE); - for (const r of toDelete) { - await resource.delete(r[this._resourceConfig.columns.find(c => c.primaryKey)!.name]); - console.log(`AutoRemovePlugin: deleted record ${r[this._resourceConfig.columns.find(c => c.primaryKey)!.name]} due to time-based limit`); + await Promise.all(deletePackage.map(r => resource.delete(r[pkColumn]))); } + console.log( + `AutoRemovePlugin: deleted ${toDelete.length} records due to time-based limit` + ); } setupEndpoints(server: IHttpServer) { diff --git a/types.ts b/types.ts index 23a4e4c..1b91ef5 100644 --- a/types.ts +++ b/types.ts @@ -28,22 +28,25 @@ export interface PluginOptions { /** * for count-based mode (100', '1k', '10k', '1m') */ - maxItems?: HumanNumber; + keepAtLeast?: HumanNumber; /** - * Max age of otem for time-based режиму ('1d', '7d', '1mon', '1y') + * Minimum number of items to always keep in count-based mode. + * This acts as a safety threshold together with `keepAtLeast`. + * Example formats: '100', '1k', '10k', '1m'. + * + * Validation ensures that minItemsKeep <= keepAtLeast. + */ + minItemsKeep?: HumanNumber; + + /** + * Max age of item for time-based mode ('1d', '7d', '1mon', '1y') */ - maxAge?: HumanDuration; + deleteOlderThan?: HumanDuration; /** * Interval for running cleanup (e.g. '1h', '1d') * Default '1d' */ interval?: HumanDuration; - - /** - * Delete no more than X items per run - * Default 500 - */ - maxDeletePerRun?: number; } diff --git a/utils/parseDuration.ts b/utils/parseDuration.ts index 3ab2577..d111e97 100644 --- a/utils/parseDuration.ts +++ b/utils/parseDuration.ts @@ -1,15 +1,15 @@ const UNITS: Record = { s: 1000, - min: 60_000, + m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000, - mon: 2_592_000_000, + mo: 2_592_000_000, y: 31_536_000_000, }; export function parseDuration(value: string): number { - const match = value.match(/^(\d+)\s*(s|min|h|d|w|mon|y)$/); + const match = value.match(/^(\d+)\s*(s|m|h|d|w|mo|y)$/); if (!match) { throw new Error(`Invalid duration format: ${value}`); } @@ -17,3 +17,4 @@ export function parseDuration(value: string): number { const [, amount, unit] = match; return Number(amount) * UNITS[unit]; } +