Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2b61be0
Remove MAX_DELETE_PER_RUN
Feb 12, 2026
bd1a2d1
fix: change error message for created at field
Feb 12, 2026
4a81321
Fix: change error message for date/datetime
Feb 12, 2026
0950aab
Refactor: streamline resource handling in AutoRemovePlugin
Feb 12, 2026
983f8c6
Add: add plugin option minItemsKeep
Feb 12, 2026
71a7727
Fix: validate minItemsKeep against maxItems in count-based mode
Feb 12, 2026
14532ae
Refactor: remove maxDeletePerRun option and adjust deletion logic in …
Feb 13, 2026
54500d0
Fix: rename options maxAge to deleteOlderThan and maxItems to keepAtL…
Feb 13, 2026
a508c2a
Refactor: optimize deletion logic in cleanupByCount and cleanupByTime…
Feb 13, 2026
42f1d10
fix: resolve copilot comment
Feb 16, 2026
85f6960
fix: added global variable ITEMS_PER_DELETE instead of local itemsPer…
Feb 16, 2026
468ee84
add description of plugin work as documentation in README.md
Feb 16, 2026
b1a90bc
fix: correct unit keys in UNITS object for consistency
Feb 16, 2026
15a18b3
fix: correct spelling in README, check resourceConfig and update cod…
Feb 17, 2026
fdb9d31
fix: correct regex for duration parsing to use consistent unit abbrev…
Feb 19, 2026
3da9fcb
fix: pass resourceConfig to cleanup methods for improved record deletion
Mar 12, 2026
35ef1f8
chore: update documentation
Mar 12, 2026
f4f0b14
fix: add check for mode name, for required minItemsKeep and update ch…
Mar 13, 2026
6d26ef9
fix: update required check
Mar 16, 2026
268bdce
fix: update doc valid duration
Mar 17, 2026
17fe903
fix: resolve copilot comment
Mar 17, 2026
87c58b3
fix: remove batch deletion
Mar 17, 2026
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', '1mo', '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.
91 changes: 50 additions & 41 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,11 @@ 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;

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,73 +21,87 @@ 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 !== '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.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');
}
if (this.options.mode === 'count-based' && !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!);
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);
Comment on lines +75 to +81
const pkColumn = this.resource.columns.find(c => c.primaryKey)!.name;

const ids = toDelete.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)
.slice(0, this.options.maxDeletePerRun || MAX_DELETE_PER_RUN);
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);

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`);
}
const pkColumn = this.resource.columns.find(c => c.primaryKey)!.name;
const ids = toDelete.map(r => r[pkColumn]);

await resource.dataConnector.deleteMany({ resource: resourceConfig, recordIds: ids });

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', '1mo', '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];
}