Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 102 additions & 2 deletions README.md
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.
79 changes: 46 additions & 33 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
}
}

Expand All @@ -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])));
Comment on lines +83 to +85
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Batch deletion uses Promise.all() to delete 100 items in parallel, which could overwhelm the database with concurrent delete operations. Consider using sequential deletion or limiting concurrency (e.g., using a concurrency limiter pattern) to avoid potential database connection pool exhaustion or deadlocks, especially when deleting large numbers of records.

Copilot uses AI. Check for mistakes.
}

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);
Copy link

Copilot AI Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The property this.resource might be undefined when cleanupByTime is called. Access to this.resource.resourceId and this.resource.columns on lines 91 and 96 could throw runtime errors if the resource is undefined.

Copilot uses AI. Check for mistakes.

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) {
Expand Down
21 changes: 12 additions & 9 deletions types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
7 changes: 4 additions & 3 deletions utils/parseDuration.ts
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];
}