-
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
Open
kulikp1
wants to merge
18
commits into
main
Choose a base branch
from
next
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Next #1
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
2b61be0
Remove MAX_DELETE_PER_RUN
bd1a2d1
fix: change error message for created at field
4a81321
Fix: change error message for date/datetime
0950aab
Refactor: streamline resource handling in AutoRemovePlugin
983f8c6
Add: add plugin option minItemsKeep
71a7727
Fix: validate minItemsKeep against maxItems in count-based mode
14532ae
Refactor: remove maxDeletePerRun option and adjust deletion logic in …
54500d0
Fix: rename options maxAge to deleteOlderThan and maxItems to keepAtL…
a508c2a
Refactor: optimize deletion logic in cleanupByCount and cleanupByTime…
42f1d10
fix: resolve copilot comment
85f6960
fix: added global variable ITEMS_PER_DELETE instead of local itemsPer…
468ee84
add description of plugin work as documentation in README.md
b1a90bc
fix: correct unit keys in UNITS object for consistency
15a18b3
fix: correct spelling in README, check resourceConfig and update cod…
fdb9d31
fix: correct regex for duration parsing to use consistent unit abbrev…
3da9fcb
fix: pass resourceConfig to cleanup methods for improved record deletion
35ef1f8
chore: update documentation
f4f0b14
fix: add check for mode name, for required minItemsKeep and update ch…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: '1mo', | ||
| minItemsKeep: '180', | ||
| }), | ||
| ``` | ||
|
|
||
| for time-based mode | ||
| ```ts | ||
| new AutoRemovePlugin({ | ||
| createdAtField: 'created_at', | ||
| mode: 'time-based', | ||
| deleteOlderThan: '3mo', | ||
| interval: '1mo', | ||
| }), | ||
| ``` | ||
|
|
||
| --- | ||
|
|
||
| ## 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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,73 +23,92 @@ 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}, but required"`); | ||
| 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.deleteOlderThan) { | ||
| throw new Error('deleteOlderThan is required for time-based mode'); | ||
| } | ||
| 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.mode !== 'count-based'){ | ||
| throw new Error(`wrong delete mode "${this.options.mode}", please set "time-based" or "count-based"`); | ||
| } | ||
| if (!this.options.minItemsKeep){ | ||
| throw new Error('minItemsKeep is required'); | ||
| } | ||
| } | ||
|
|
||
| private async runCleanup(adminforth: IAdminForth) { | ||
| try { | ||
| if (this.options.mode === 'count-based') { | ||
| await this.cleanupByCount(adminforth); | ||
| await this.cleanupByCount(adminforth, this.resourceConfig); | ||
| } else { | ||
| await this.cleanupByTime(adminforth); | ||
| await this.cleanupByTime(adminforth, this.resourceConfig); | ||
| } | ||
| } catch (err) { | ||
| console.error('AutoRemovePlugin runCleanup error:', err); | ||
| } | ||
| } | ||
|
|
||
| private async cleanupByCount(adminforth: IAdminForth) { | ||
| const limit = parseHumanNumber(this.options.maxItems!); | ||
| const resource = adminforth.resource(this._resourceConfig.resourceId); | ||
| private async cleanupByCount(adminforth: IAdminForth, resourceConfig: AdminForthResource) { | ||
| 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); | ||
| const ids = deletePackage.map(r => r[pkColumn]); | ||
| await resource.dataConnector.deleteMany({ resource: resourceConfig, recordIds: ids }); | ||
| } | ||
| console.log(`AutoRemovePlugin: deleted ${toDelete.length} records due to count-based limit`); | ||
| } | ||
|
|
||
| private async cleanupByTime(adminforth: IAdminForth) { | ||
| const maxAgeMs = parseDuration(this.options.maxAge!); | ||
| private async cleanupByTime(adminforth: IAdminForth, resourceConfig: AdminForthResource) { | ||
| 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); | ||
| const ids = deletePackage.map(r => r[pkColumn]); | ||
|
|
||
| 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 resource.dataConnector.deleteMany({ resource: resourceConfig, recordIds: ids }); | ||
| } | ||
| console.log(`AutoRemovePlugin: deleted ${toDelete.length} records due to time-based limit`); | ||
| } | ||
|
|
||
| setupEndpoints(server: IHttpServer) { | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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]; | ||
| } | ||
|
|
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.