@adminforth/auto-remove 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.woodpecker/buildRelease.sh +13 -0
- package/.woodpecker/buildSlackNotify.sh +46 -0
- package/.woodpecker/release.yml +57 -0
- package/README.md +104 -0
- package/build.log +10 -0
- package/custom/tsconfig.json +16 -0
- package/dist/custom/tsconfig.json +16 -0
- package/dist/index.js +119 -0
- package/dist/types.js +1 -0
- package/dist/utils/parseDuration.js +17 -0
- package/dist/utils/parseNumber.js +10 -0
- package/index.ts +119 -0
- package/package.json +51 -0
- package/tsconfig.json +13 -0
- package/types.ts +52 -0
- package/utils/parseDuration.ts +20 -0
- package/utils/parseNumber.ts +9 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
|
|
2
|
+
#!/bin/bash
|
|
3
|
+
|
|
4
|
+
# write npm run output both to console and to build.log
|
|
5
|
+
npm run build 2>&1 | tee build.log
|
|
6
|
+
build_status=${PIPESTATUS[0]}
|
|
7
|
+
|
|
8
|
+
# if exist status from the npm run build is not 0
|
|
9
|
+
# then exit with the status code from the npm run build
|
|
10
|
+
if [ $build_status -ne 0 ]; then
|
|
11
|
+
echo "Build failed. Exiting with status code $build_status"
|
|
12
|
+
exit $build_status
|
|
13
|
+
fi
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
|
|
3
|
+
set -x
|
|
4
|
+
|
|
5
|
+
COMMIT_SHORT_SHA=$(echo $CI_COMMIT_SHA | cut -c1-8)
|
|
6
|
+
|
|
7
|
+
STATUS=${1}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
if [ "$STATUS" = "success" ]; then
|
|
11
|
+
MESSAGE="Did a build without issues on \`$CI_REPO_NAME/$CI_COMMIT_BRANCH\`. Commit: _${CI_COMMIT_MESSAGE}_ (<$CI_COMMIT_URL|$COMMIT_SHORT_SHA>)"
|
|
12
|
+
|
|
13
|
+
curl -s -X POST -H "Content-Type: application/json" -d '{
|
|
14
|
+
"username": "'"$CI_COMMIT_AUTHOR"'",
|
|
15
|
+
"icon_url": "'"$CI_COMMIT_AUTHOR_AVATAR"'",
|
|
16
|
+
"attachments": [
|
|
17
|
+
{
|
|
18
|
+
"mrkdwn_in": ["text", "pretext"],
|
|
19
|
+
"color": "#36a64f",
|
|
20
|
+
"text": "'"$MESSAGE"'"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}' "$DEVELOPERS_SLACK_WEBHOOK"
|
|
24
|
+
exit 0
|
|
25
|
+
fi
|
|
26
|
+
export BUILD_LOG=$(cat ./build.log)
|
|
27
|
+
|
|
28
|
+
BUILD_LOG=$(echo $BUILD_LOG | sed 's/"/\\"/g')
|
|
29
|
+
|
|
30
|
+
MESSAGE="Broke \`$CI_REPO_NAME/$CI_COMMIT_BRANCH\` with commit _${CI_COMMIT_MESSAGE}_ (<$CI_COMMIT_URL|$COMMIT_SHORT_SHA>)"
|
|
31
|
+
CODE_BLOCK="\`\`\`$BUILD_LOG\n\`\`\`"
|
|
32
|
+
|
|
33
|
+
echo "Sending slack message to developers $MESSAGE"
|
|
34
|
+
# Send the message
|
|
35
|
+
curl -sS -X POST -H "Content-Type: application/json" -d '{
|
|
36
|
+
"username": "'"$CI_COMMIT_AUTHOR"'",
|
|
37
|
+
"icon_url": "'"$CI_COMMIT_AUTHOR_AVATAR"'",
|
|
38
|
+
"attachments": [
|
|
39
|
+
{
|
|
40
|
+
"mrkdwn_in": ["text", "pretext"],
|
|
41
|
+
"color": "#8A1C12",
|
|
42
|
+
"text": "'"$CODE_BLOCK"'",
|
|
43
|
+
"pretext": "'"$MESSAGE"'"
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}' "$DEVELOPERS_SLACK_WEBHOOK" 2>&1
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
clone:
|
|
2
|
+
git:
|
|
3
|
+
image: woodpeckerci/plugin-git
|
|
4
|
+
settings:
|
|
5
|
+
partial: false
|
|
6
|
+
depth: 5
|
|
7
|
+
|
|
8
|
+
steps:
|
|
9
|
+
init-secrets:
|
|
10
|
+
when:
|
|
11
|
+
- event: push
|
|
12
|
+
image: infisical/cli
|
|
13
|
+
environment:
|
|
14
|
+
INFISICAL_TOKEN:
|
|
15
|
+
from_secret: VAULT_TOKEN
|
|
16
|
+
commands:
|
|
17
|
+
- infisical export --domain https://vault.devforth.io/api --format=dotenv-export --env="prod" > /woodpecker/deploy.vault.env
|
|
18
|
+
|
|
19
|
+
build:
|
|
20
|
+
image: devforth/node20-pnpm:latest
|
|
21
|
+
when:
|
|
22
|
+
- event: push
|
|
23
|
+
commands:
|
|
24
|
+
- apt update && apt install -y rsync
|
|
25
|
+
- . /woodpecker/deploy.vault.env
|
|
26
|
+
- pnpm install
|
|
27
|
+
- /bin/bash ./.woodpecker/buildRelease.sh
|
|
28
|
+
- npm audit signatures
|
|
29
|
+
|
|
30
|
+
release:
|
|
31
|
+
image: devforth/node20-pnpm:latest
|
|
32
|
+
when:
|
|
33
|
+
- event:
|
|
34
|
+
- push
|
|
35
|
+
branch:
|
|
36
|
+
- main
|
|
37
|
+
commands:
|
|
38
|
+
- . /woodpecker/deploy.vault.env
|
|
39
|
+
- pnpm exec semantic-release
|
|
40
|
+
|
|
41
|
+
slack-on-failure:
|
|
42
|
+
image: curlimages/curl
|
|
43
|
+
when:
|
|
44
|
+
- event: push
|
|
45
|
+
status: [failure]
|
|
46
|
+
commands:
|
|
47
|
+
- . /woodpecker/deploy.vault.env
|
|
48
|
+
- /bin/sh ./.woodpecker/buildSlackNotify.sh failure
|
|
49
|
+
|
|
50
|
+
slack-on-success:
|
|
51
|
+
image: curlimages/curl
|
|
52
|
+
when:
|
|
53
|
+
- event: push
|
|
54
|
+
status: [success]
|
|
55
|
+
commands:
|
|
56
|
+
- . /woodpecker/deploy.vault.env
|
|
57
|
+
- /bin/sh ./.woodpecker/buildSlackNotify.sh success
|
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Auto Remove Plugin
|
|
2
|
+
|
|
3
|
+
This plugin removes records from resources based on **count-based** or **time-based** rules.
|
|
4
|
+
|
|
5
|
+
It is designed for cleaning up:
|
|
6
|
+
|
|
7
|
+
* old records
|
|
8
|
+
* logs
|
|
9
|
+
* demo/test data
|
|
10
|
+
* temporary entities
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
To install the plugin:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
npm install @adminforth/auto-remove
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Import it into your resource:
|
|
23
|
+
```ts
|
|
24
|
+
import AutoRemovePlugin from '../../plugins/adminforth-auto-remove/index.js';
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Plugin Options
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
export interface PluginOptions {
|
|
31
|
+
createdAtField: string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* - count-based: Delete items > keepAtLeast
|
|
35
|
+
* - time-based: Delete age > deleteOlderThan
|
|
36
|
+
*/
|
|
37
|
+
mode: AutoRemoveMode;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* for count-based mode (100', '1k', '10k', '1m')
|
|
41
|
+
*/
|
|
42
|
+
keepAtLeast?: HumanNumber;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Minimum number of items to always keep in count-based mode.
|
|
46
|
+
* This acts as a safety threshold together with `keepAtLeast`.
|
|
47
|
+
* Example formats: '100', '1k', '10k', '1m'.
|
|
48
|
+
*
|
|
49
|
+
* Validation ensures that minItemsKeep <= keepAtLeast.
|
|
50
|
+
*/
|
|
51
|
+
minItemsKeep?: HumanNumber;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Max age of item for time-based mode ('1d', '7d', '1mo', '1y')
|
|
55
|
+
*/
|
|
56
|
+
deleteOlderThan?: HumanDuration;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Interval for running cleanup (e.g. '1h', '1d')
|
|
60
|
+
* Default '1d'
|
|
61
|
+
*/
|
|
62
|
+
interval?: HumanDuration;
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
To use the plugin, add it to your resource file. Here's an example:
|
|
69
|
+
|
|
70
|
+
for count-based mode
|
|
71
|
+
```ts
|
|
72
|
+
new AutoRemovePlugin({
|
|
73
|
+
createdAtField: 'created_at',
|
|
74
|
+
mode: 'count-based',
|
|
75
|
+
keepAtLeast: '200',
|
|
76
|
+
interval: '1mo',
|
|
77
|
+
minItemsKeep: '180',
|
|
78
|
+
}),
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
for time-based mode
|
|
82
|
+
```ts
|
|
83
|
+
new AutoRemovePlugin({
|
|
84
|
+
createdAtField: 'created_at',
|
|
85
|
+
mode: 'time-based',
|
|
86
|
+
deleteOlderThan: '3mo',
|
|
87
|
+
interval: '1mo',
|
|
88
|
+
}),
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Result
|
|
94
|
+
After running **AutoRemovePlugin**, old or excess records are deleted automatically:
|
|
95
|
+
|
|
96
|
+
- **Count-based mode:** keeps the newest `keepAtLeast` records, deletes older ones.
|
|
97
|
+
Example: `keepAtLeast = 500` → table with 650 records deletes 150 oldest.
|
|
98
|
+
|
|
99
|
+
- **Time-based mode:** deletes records older than `deleteOlderThan`.
|
|
100
|
+
Example: `deleteOlderThan = '7d'` → removes records older than 7 days.
|
|
101
|
+
|
|
102
|
+
- **Manual cleanup:** `POST /plugin/{pluginInstanceId}/cleanup`, returns `{ "ok": true }`.
|
|
103
|
+
|
|
104
|
+
Logs show how many records were removed per run.
|
package/build.log
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"baseUrl": ".", // This should point to your project root
|
|
4
|
+
"paths": {
|
|
5
|
+
"@/*": [
|
|
6
|
+
"../node_modules/adminforth/dist/spa/src/*"
|
|
7
|
+
],
|
|
8
|
+
"*": [
|
|
9
|
+
"../node_modules/adminforth/dist/spa/node_modules/*"
|
|
10
|
+
],
|
|
11
|
+
"@@/*": [
|
|
12
|
+
"."
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"baseUrl": ".", // This should point to your project root
|
|
4
|
+
"paths": {
|
|
5
|
+
"@/*": [
|
|
6
|
+
"../node_modules/adminforth/dist/spa/src/*"
|
|
7
|
+
],
|
|
8
|
+
"*": [
|
|
9
|
+
"../node_modules/adminforth/dist/spa/node_modules/*"
|
|
10
|
+
],
|
|
11
|
+
"@@/*": [
|
|
12
|
+
"."
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
2
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
3
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
4
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
5
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
6
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
7
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
|
+
});
|
|
9
|
+
};
|
|
10
|
+
import { AdminForthPlugin, Sorts, AdminForthDataTypes } from "adminforth";
|
|
11
|
+
import { parseHumanNumber } from './utils/parseNumber.js';
|
|
12
|
+
import { parseDuration } from './utils/parseDuration.js';
|
|
13
|
+
export default class AutoRemovePlugin extends AdminForthPlugin {
|
|
14
|
+
constructor(options) {
|
|
15
|
+
super(options, import.meta.url);
|
|
16
|
+
this.options = options;
|
|
17
|
+
this.shouldHaveSingleInstancePerWholeApp = () => false;
|
|
18
|
+
}
|
|
19
|
+
instanceUniqueRepresentation(pluginOptions) {
|
|
20
|
+
return `single`;
|
|
21
|
+
}
|
|
22
|
+
modifyResourceConfig(adminforth, resourceConfig) {
|
|
23
|
+
const _super = Object.create(null, {
|
|
24
|
+
modifyResourceConfig: { get: () => super.modifyResourceConfig }
|
|
25
|
+
});
|
|
26
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
27
|
+
_super.modifyResourceConfig.call(this, adminforth, resourceConfig);
|
|
28
|
+
if (resourceConfig) {
|
|
29
|
+
this.resource = resourceConfig;
|
|
30
|
+
}
|
|
31
|
+
const intervalMs = parseDuration(this.options.interval || '1d');
|
|
32
|
+
this.timer = setInterval(() => {
|
|
33
|
+
this.runCleanup(adminforth).catch(console.error);
|
|
34
|
+
}, intervalMs);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
validateConfigAfterDiscover(adminforth, resourceConfig) {
|
|
38
|
+
const col = resourceConfig.columns.find(c => c.name === this.options.createdAtField);
|
|
39
|
+
if (!col)
|
|
40
|
+
throw new Error(`Field "${this.options.createdAtField}" not found in resource "${resourceConfig.label}", but required`);
|
|
41
|
+
if (![AdminForthDataTypes.DATE, AdminForthDataTypes.DATETIME].includes(col.type)) {
|
|
42
|
+
throw new Error(`Field "${this.options.createdAtField}" in resource "${resourceConfig.label}" must be of type DATE or DATETIME`);
|
|
43
|
+
}
|
|
44
|
+
if (this.options.mode !== 'time-based' && this.options.mode !== 'count-based') {
|
|
45
|
+
throw new Error(`wrong delete mode "${this.options.mode}", please set "time-based" or "count-based"`);
|
|
46
|
+
}
|
|
47
|
+
if (this.options.mode === 'count-based') {
|
|
48
|
+
if (!this.options.keepAtLeast) {
|
|
49
|
+
throw new Error('keepAtLeast is required for count-based mode');
|
|
50
|
+
}
|
|
51
|
+
if (this.options.minItemsKeep && parseHumanNumber(this.options.minItemsKeep) > parseHumanNumber(this.options.keepAtLeast)) {
|
|
52
|
+
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"`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (this.options.mode === 'time-based' && !this.options.deleteOlderThan) {
|
|
56
|
+
throw new Error('deleteOlderThan is required for time-based mode');
|
|
57
|
+
}
|
|
58
|
+
if (this.options.mode === 'count-based' && !this.options.minItemsKeep) {
|
|
59
|
+
throw new Error('minItemsKeep is required');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
runCleanup(adminforth) {
|
|
63
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
64
|
+
try {
|
|
65
|
+
if (this.options.mode === 'count-based') {
|
|
66
|
+
yield this.cleanupByCount(adminforth, this.resourceConfig);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
yield this.cleanupByTime(adminforth, this.resourceConfig);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
console.error('AutoRemovePlugin runCleanup error:', err);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
cleanupByCount(adminforth, resourceConfig) {
|
|
78
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
79
|
+
const limit = parseHumanNumber(this.options.keepAtLeast);
|
|
80
|
+
const resource = adminforth.resource(this.resource.resourceId);
|
|
81
|
+
const allRecords = yield resource.list([], null, null, [Sorts.ASC(this.options.createdAtField)]);
|
|
82
|
+
if (allRecords.length <= limit)
|
|
83
|
+
return;
|
|
84
|
+
const toDelete = allRecords.slice(0, allRecords.length - limit);
|
|
85
|
+
const pkColumn = this.resource.columns.find(c => c.primaryKey).name;
|
|
86
|
+
const ids = toDelete.map(r => r[pkColumn]);
|
|
87
|
+
yield resource.dataConnector.deleteMany({ resource: resourceConfig, recordIds: ids });
|
|
88
|
+
console.log(`AutoRemovePlugin: deleted ${toDelete.length} records due to count-based limit`);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
cleanupByTime(adminforth, resourceConfig) {
|
|
92
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
93
|
+
const maxAgeMs = parseDuration(this.options.deleteOlderThan);
|
|
94
|
+
const threshold = Date.now() - maxAgeMs;
|
|
95
|
+
const resource = adminforth.resource(this.resource.resourceId);
|
|
96
|
+
const allRecords = yield resource.list([], null, null, [Sorts.ASC(this.options.createdAtField)]);
|
|
97
|
+
const toDelete = allRecords.filter(r => new Date(r[this.options.createdAtField]).getTime() < threshold);
|
|
98
|
+
const pkColumn = this.resource.columns.find(c => c.primaryKey).name;
|
|
99
|
+
const ids = toDelete.map(r => r[pkColumn]);
|
|
100
|
+
yield resource.dataConnector.deleteMany({ resource: resourceConfig, recordIds: ids });
|
|
101
|
+
console.log(`AutoRemovePlugin: deleted ${toDelete.length} records due to time-based limit`);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
setupEndpoints(server) {
|
|
105
|
+
server.endpoint({
|
|
106
|
+
method: 'POST',
|
|
107
|
+
path: `/plugin/${this.pluginInstanceId}/cleanup`,
|
|
108
|
+
handler: () => __awaiter(this, void 0, void 0, function* () {
|
|
109
|
+
try {
|
|
110
|
+
yield this.runCleanup(this.adminforth);
|
|
111
|
+
return { ok: true };
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const UNITS = {
|
|
2
|
+
s: 1000,
|
|
3
|
+
m: 60000,
|
|
4
|
+
h: 3600000,
|
|
5
|
+
d: 86400000,
|
|
6
|
+
w: 604800000,
|
|
7
|
+
mo: 2592000000,
|
|
8
|
+
y: 31536000000,
|
|
9
|
+
};
|
|
10
|
+
export function parseDuration(value) {
|
|
11
|
+
const match = value.match(/^(\d+)\s*(s|m|h|d|w|mo|y)$/);
|
|
12
|
+
if (!match) {
|
|
13
|
+
throw new Error(`Invalid duration format: ${value}`);
|
|
14
|
+
}
|
|
15
|
+
const [, amount, unit] = match;
|
|
16
|
+
return Number(amount) * UNITS[unit];
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function parseHumanNumber(value) {
|
|
2
|
+
const v = value.toLowerCase().trim();
|
|
3
|
+
if (v.endsWith('kk'))
|
|
4
|
+
return Number(v.slice(0, -2)) * 1000000;
|
|
5
|
+
if (v.endsWith('k'))
|
|
6
|
+
return Number(v.slice(0, -1)) * 1000;
|
|
7
|
+
if (v.endsWith('m'))
|
|
8
|
+
return Number(v.slice(0, -1)) * 1000000;
|
|
9
|
+
return Number(v);
|
|
10
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { AdminForthPlugin, Filters, Sorts, AdminForthDataTypes } from "adminforth";
|
|
2
|
+
import type { IAdminForth, IHttpServer, AdminForthResource } from "adminforth";
|
|
3
|
+
import type { PluginOptions } from './types.js';
|
|
4
|
+
import { parseHumanNumber } from './utils/parseNumber.js';
|
|
5
|
+
import { parseDuration } from './utils/parseDuration.js';
|
|
6
|
+
|
|
7
|
+
export default class AutoRemovePlugin extends AdminForthPlugin {
|
|
8
|
+
options: PluginOptions;
|
|
9
|
+
resource?: AdminForthResource;
|
|
10
|
+
timer?: NodeJS.Timeout;
|
|
11
|
+
|
|
12
|
+
constructor(options: PluginOptions) {
|
|
13
|
+
super(options, import.meta.url);
|
|
14
|
+
this.options = options;
|
|
15
|
+
this.shouldHaveSingleInstancePerWholeApp = () => false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
instanceUniqueRepresentation(pluginOptions: any) : string {
|
|
19
|
+
return `single`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
|
|
23
|
+
super.modifyResourceConfig(adminforth, resourceConfig);
|
|
24
|
+
|
|
25
|
+
if (resourceConfig) {
|
|
26
|
+
this.resource = resourceConfig;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const intervalMs = parseDuration(this.options.interval || '1d');
|
|
30
|
+
this.timer = setInterval(() => {
|
|
31
|
+
this.runCleanup(adminforth).catch(console.error);
|
|
32
|
+
}, intervalMs);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
|
|
36
|
+
const col = resourceConfig.columns.find(c => c.name === this.options.createdAtField);
|
|
37
|
+
if (!col) throw new Error(`Field "${this.options.createdAtField}" not found in resource "${resourceConfig.label}", but required`);
|
|
38
|
+
if (![AdminForthDataTypes.DATE, AdminForthDataTypes.DATETIME].includes(col.type!)) {
|
|
39
|
+
throw new Error(`Field "${this.options.createdAtField}" in resource "${resourceConfig.label}" must be of type DATE or DATETIME`);
|
|
40
|
+
}
|
|
41
|
+
if (this.options.mode !== 'time-based' && this.options.mode !== 'count-based'){
|
|
42
|
+
throw new Error(`wrong delete mode "${this.options.mode}", please set "time-based" or "count-based"`);
|
|
43
|
+
}
|
|
44
|
+
if (this.options.mode === 'count-based') {
|
|
45
|
+
if (!this.options.keepAtLeast) {
|
|
46
|
+
throw new Error('keepAtLeast is required for count-based mode');
|
|
47
|
+
}
|
|
48
|
+
if (this.options.minItemsKeep && parseHumanNumber(this.options.minItemsKeep) > parseHumanNumber(this.options.keepAtLeast)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Option "minItemsKeep" (${this.options.minItemsKeep}) cannot be greater than "keepAtLeast" (${this.options.keepAtLeast}). Please set "minItemsKeep" less than or equal to "keepAtLeast"`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (this.options.mode === 'time-based' && !this.options.deleteOlderThan) {
|
|
55
|
+
throw new Error('deleteOlderThan is required for time-based mode');
|
|
56
|
+
}
|
|
57
|
+
if (this.options.mode === 'count-based' && !this.options.minItemsKeep){
|
|
58
|
+
throw new Error('minItemsKeep is required');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private async runCleanup(adminforth: IAdminForth) {
|
|
63
|
+
try {
|
|
64
|
+
if (this.options.mode === 'count-based') {
|
|
65
|
+
await this.cleanupByCount(adminforth, this.resourceConfig);
|
|
66
|
+
} else {
|
|
67
|
+
await this.cleanupByTime(adminforth, this.resourceConfig);
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error('AutoRemovePlugin runCleanup error:', err);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async cleanupByCount(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
|
|
75
|
+
const limit = parseHumanNumber(this.options.keepAtLeast!);
|
|
76
|
+
const resource = adminforth.resource(this.resource.resourceId);
|
|
77
|
+
|
|
78
|
+
const allRecords = await resource.list([], null, null, [Sorts.ASC(this.options.createdAtField)]);
|
|
79
|
+
if (allRecords.length <= limit) return;
|
|
80
|
+
|
|
81
|
+
const toDelete = allRecords.slice(0, allRecords.length - limit);
|
|
82
|
+
const pkColumn = this.resource.columns.find(c => c.primaryKey)!.name;
|
|
83
|
+
const ids = toDelete.map(r => r[pkColumn]);
|
|
84
|
+
|
|
85
|
+
await resource.dataConnector.deleteMany({ resource: resourceConfig, recordIds: ids });
|
|
86
|
+
|
|
87
|
+
console.log(`AutoRemovePlugin: deleted ${toDelete.length} records due to count-based limit`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private async cleanupByTime(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
|
|
91
|
+
const maxAgeMs = parseDuration(this.options.deleteOlderThan!);
|
|
92
|
+
const threshold = Date.now() - maxAgeMs;
|
|
93
|
+
const resource = adminforth.resource(this.resource.resourceId);
|
|
94
|
+
|
|
95
|
+
const allRecords = await resource.list([], null, null, [Sorts.ASC(this.options.createdAtField)]);
|
|
96
|
+
const toDelete = allRecords.filter(r => new Date(r[this.options.createdAtField]).getTime() < threshold);
|
|
97
|
+
|
|
98
|
+
const pkColumn = this.resource.columns.find(c => c.primaryKey)!.name;
|
|
99
|
+
const ids = toDelete.map(r => r[pkColumn]);
|
|
100
|
+
await resource.dataConnector.deleteMany({ resource: resourceConfig, recordIds: ids });
|
|
101
|
+
|
|
102
|
+
console.log(`AutoRemovePlugin: deleted ${toDelete.length} records due to time-based limit`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
setupEndpoints(server: IHttpServer) {
|
|
106
|
+
server.endpoint({
|
|
107
|
+
method: 'POST',
|
|
108
|
+
path: `/plugin/${this.pluginInstanceId}/cleanup`,
|
|
109
|
+
handler: async () => {
|
|
110
|
+
try {
|
|
111
|
+
await this.runCleanup(this.adminforth);
|
|
112
|
+
return { ok: true };
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@adminforth/auto-remove",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"main": "dist/index.js",
|
|
5
|
+
"types": "dist/index.d.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc && rsync -av --exclude 'node_modules' custom dist/"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [],
|
|
14
|
+
"author": "",
|
|
15
|
+
"license": "ISC",
|
|
16
|
+
"description": "",
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "latest",
|
|
19
|
+
"semantic-release": "^24.2.1",
|
|
20
|
+
"semantic-release-slack-bot": "^4.0.2",
|
|
21
|
+
"typescript": "^5.7.3"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"adminforth": "2.25.4"
|
|
25
|
+
},
|
|
26
|
+
"release": {
|
|
27
|
+
"plugins": [
|
|
28
|
+
"@semantic-release/commit-analyzer",
|
|
29
|
+
"@semantic-release/release-notes-generator",
|
|
30
|
+
"@semantic-release/npm",
|
|
31
|
+
"@semantic-release/github",
|
|
32
|
+
[
|
|
33
|
+
"semantic-release-slack-bot",
|
|
34
|
+
{
|
|
35
|
+
"packageName": "@adminforth/auto-remove",
|
|
36
|
+
"notifyOnSuccess": true,
|
|
37
|
+
"notifyOnFail": true,
|
|
38
|
+
"slackIcon": ":package:",
|
|
39
|
+
"markdownReleaseNotes": true
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
],
|
|
43
|
+
"branches": [
|
|
44
|
+
"main",
|
|
45
|
+
{
|
|
46
|
+
"name": "next",
|
|
47
|
+
"prerelease": true
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include*/
|
|
4
|
+
"module": "node16", /* Specify what module code is generated. */
|
|
5
|
+
"outDir": "./dist", /* Specify an output folder for all emitted files. */
|
|
6
|
+
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. */
|
|
7
|
+
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
|
8
|
+
"strict": false, /* Enable all strict type-checking options. */
|
|
9
|
+
"skipLibCheck": true, /* Skip type checking all .d.ts files. */
|
|
10
|
+
},
|
|
11
|
+
"exclude": ["node_modules", "dist", "custom"], /* Exclude files from compilation. */
|
|
12
|
+
}
|
|
13
|
+
|
package/types.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type AutoRemoveMode = 'count-based' | 'time-based';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* count-based 100, 1k, 1kk, 1m
|
|
5
|
+
*/
|
|
6
|
+
export type HumanNumber = string;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* s
|
|
10
|
+
* min
|
|
11
|
+
* h
|
|
12
|
+
* d
|
|
13
|
+
* w
|
|
14
|
+
* mon
|
|
15
|
+
* y
|
|
16
|
+
*/
|
|
17
|
+
export type HumanDuration = string;
|
|
18
|
+
|
|
19
|
+
export interface PluginOptions {
|
|
20
|
+
createdAtField: string;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* - count-based: Delete items > maxItems
|
|
24
|
+
* - time-based: Delete age > maxAge
|
|
25
|
+
*/
|
|
26
|
+
mode: AutoRemoveMode;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* for count-based mode (100', '1k', '10k', '1m')
|
|
30
|
+
*/
|
|
31
|
+
keepAtLeast?: HumanNumber;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Minimum number of items to always keep in count-based mode.
|
|
35
|
+
* This acts as a safety threshold together with `keepAtLeast`.
|
|
36
|
+
* Example formats: '100', '1k', '10k', '1m'.
|
|
37
|
+
*
|
|
38
|
+
* Validation ensures that minItemsKeep <= keepAtLeast.
|
|
39
|
+
*/
|
|
40
|
+
minItemsKeep?: HumanNumber;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Max age of item for time-based mode ('1d', '7d', '1mo', '1y')
|
|
44
|
+
*/
|
|
45
|
+
deleteOlderThan?: HumanDuration;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Interval for running cleanup (e.g. '1h', '1d')
|
|
49
|
+
* Default '1d'
|
|
50
|
+
*/
|
|
51
|
+
interval?: HumanDuration;
|
|
52
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const UNITS: Record<string, number> = {
|
|
2
|
+
s: 1000,
|
|
3
|
+
m: 60_000,
|
|
4
|
+
h: 3_600_000,
|
|
5
|
+
d: 86_400_000,
|
|
6
|
+
w: 604_800_000,
|
|
7
|
+
mo: 2_592_000_000,
|
|
8
|
+
y: 31_536_000_000,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function parseDuration(value: string): number {
|
|
12
|
+
const match = value.match(/^(\d+)\s*(s|m|h|d|w|mo|y)$/);
|
|
13
|
+
if (!match) {
|
|
14
|
+
throw new Error(`Invalid duration format: ${value}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const [, amount, unit] = match;
|
|
18
|
+
return Number(amount) * UNITS[unit];
|
|
19
|
+
}
|
|
20
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function parseHumanNumber(value: string): number {
|
|
2
|
+
const v = value.toLowerCase().trim();
|
|
3
|
+
|
|
4
|
+
if (v.endsWith('kk')) return Number(v.slice(0, -2)) * 1_000_000;
|
|
5
|
+
if (v.endsWith('k')) return Number(v.slice(0, -1)) * 1_000;
|
|
6
|
+
if (v.endsWith('m')) return Number(v.slice(0, -1)) * 1_000_000;
|
|
7
|
+
|
|
8
|
+
return Number(v);
|
|
9
|
+
}
|