-
Notifications
You must be signed in to change notification settings - Fork 0
Next #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Next #1
Changes from all commits
2b61be0
bd1a2d1
4a81321
0950aab
983f8c6
71a7727
14532ae
54500d0
a508c2a
42f1d10
85f6960
468ee84
b1a90bc
15a18b3
fdb9d31
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
kulikp1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| constructor(options: PluginOptions) { | ||
| super(options, import.meta.url); | ||
|
|
@@ -26,30 +23,37 @@ 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); | ||
| }, intervalMs); | ||
| } | ||
|
|
||
| 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!); | ||
kulikp1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const resource = adminforth.resource(this.resource.resourceId); | ||
kulikp1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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]))); | ||
|
Comment on lines
+83
to
+85
|
||
| } | ||
|
|
||
| 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` | ||
| ); | ||
kulikp1 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| setupEndpoints(server: IHttpServer) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,20 @@ | ||
| const UNITS: Record<string, number> = { | ||
| 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}`); | ||
| } | ||
|
|
||
| const [, amount, unit] = match; | ||
| return Number(amount) * UNITS[unit]; | ||
| } | ||
|
|
Uh oh!
There was an error while loading. Please reload this page.