feat(cache): add persistent smartdata-backed cache with LocalTsmDb, cache cleaner, and DcRouter integration

This commit is contained in:
2026-02-10 11:22:15 +00:00
parent f3f1f58b67
commit 41fe7a8a47
15 changed files with 282 additions and 39 deletions

View File

@@ -1,5 +1,17 @@
# Changelog # Changelog
## 2026-02-10 - 4.1.0 - feat(cache)
add persistent smartdata-backed cache with LocalTsmDb, cache cleaner, and DcRouter integration
- Introduce CacheDb and CacheCleaner using @push.rocks/smartdata and @push.rocks/smartmongo (LocalTsmDb) for persistent caching
- Integrate cache initialization, console summary, and graceful shutdown into DcRouter (options.cacheConfig and setupCacheDb())
- Require svDb() decorators on concrete cache document classes; remove decorators from the abstract CachedDocument base class
- Switch CacheCleaner to smartdata getInstances() + per-document delete() instead of deleteMany
- Adapt to LocalTsmDb API changes (folderPath option and start() returning connectionUri) and initialize SmartdataDb with mongoDbUrl/mongoDbName
- Remove experimentalDecorators and emitDecoratorMetadata from tsconfig to use TC39 Stage 3 decorators (smartdata v7+ compatibility)
- Add package.json exports mapping (remove main/typings entries) to expose dist entry points
- Add README documentation for the Smartdata Cache System and configuration/usage examples
## 2026-02-03 - 4.0.0 - BREAKING CHANGE(config) ## 2026-02-03 - 4.0.0 - BREAKING CHANGE(config)
convert configuration management to read-only; remove updateConfiguration endpoint and client-side editing convert configuration management to read-only; remove updateConfiguration endpoint and client-side editing

View File

@@ -3,9 +3,11 @@
"private": false, "private": false,
"version": "4.0.0", "version": "4.0.0",
"description": "A multifaceted routing service handling mail and SMS delivery functions.", "description": "A multifaceted routing service handling mail and SMS delivery functions.",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"type": "module", "type": "module",
"exports": {
".": "./dist_ts/index.js",
"./interfaces": "./dist_ts_interfaces/index.js"
},
"author": "Task Venture Capital GmbH", "author": "Task Venture Capital GmbH",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View File

@@ -1317,3 +1317,86 @@ The configuration UI has been converted from an editable interface to a read-onl
- Byte sizes auto-formatted (B, KB, MB, GB) - Byte sizes auto-formatted (B, KB, MB, GB)
- Time values shown with "seconds" suffix - Time values shown with "seconds" suffix
- Nested objects with visual indentation - Nested objects with visual indentation
## Smartdata Cache System (2026-02-03)
### Overview
DcRouter now uses smartdata + LocalTsmDb for persistent caching. Data is stored at `/etc/dcrouter/tsmdb`.
### Technology Stack
| Layer | Package | Purpose |
|-------|---------|---------|
| ORM | `@push.rocks/smartdata` | Document classes, decorators, queries |
| Database | `@push.rocks/smartmongo` (LocalTsmDb) | Embedded TsmDB via Unix socket |
### TC39 Decorators
The project uses TC39 Stage 3 decorators (not experimental decorators). The tsconfig was updated:
- Removed `experimentalDecorators: true`
- Removed `emitDecoratorMetadata: true`
This is required for smartdata v7+ compatibility.
### Cache Document Classes
Located in `ts/cache/documents/`:
| Class | Purpose | Default TTL |
|-------|---------|-------------|
| `CachedEmail` | Email queue items | 30 days |
| `CachedIPReputation` | IP reputation lookups | 24 hours |
| `CachedBounce` | Bounce records | 30 days |
| `CachedSuppression` | Suppression list | 30 days / permanent |
| `CachedDKIMKey` | DKIM key pairs | 90 days |
### Usage Pattern
```typescript
// Document classes use smartdata decorators
@plugins.smartdata.Collection(() => getDb())
export class CachedEmail extends CachedDocument<CachedEmail> {
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
@plugins.smartdata.unI()
@plugins.smartdata.svDb()
public id: string;
// ...
}
// Query examples
const email = await CachedEmail.getInstance({ id: 'abc123' });
const pending = await CachedEmail.getInstances({ status: 'pending' });
await email.save();
await email.delete();
```
### Configuration
```typescript
const dcRouter = new DcRouter({
cacheConfig: {
enabled: true,
storagePath: '/etc/dcrouter/tsmdb',
dbName: 'dcrouter',
cleanupIntervalHours: 1,
ttlConfig: {
emails: 30, // days
ipReputation: 1, // days
bounces: 30, // days
dkimKeys: 90, // days
suppression: 30 // days
}
}
});
```
### Cache Cleaner
- Runs hourly by default (configurable via `cleanupIntervalHours`)
- Finds and deletes documents where `expiresAt < now()`
- Uses smartdata's `getInstances()` + `delete()` pattern
### Key Files
- `ts/cache/classes.cachedb.ts` - CacheDb singleton wrapper
- `ts/cache/classes.cached.document.ts` - Base class with TTL support
- `ts/cache/classes.cache.cleaner.ts` - Periodic cleanup service
- `ts/cache/documents/*.ts` - Document class definitions

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '4.0.0', version: '4.1.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -98,24 +98,20 @@ export class CacheCleaner {
const results: { collection: string; deleted: number }[] = []; const results: { collection: string; deleted: number }[] = [];
try { try {
// Clean CachedEmail documents // Clean each collection using smartdata's getInstances + delete pattern
const emailsDeleted = await this.cleanCollection(CachedEmail, now); const emailsDeleted = await this.cleanExpiredDocuments(CachedEmail, now);
results.push({ collection: 'CachedEmail', deleted: emailsDeleted }); results.push({ collection: 'CachedEmail', deleted: emailsDeleted });
// Clean CachedIPReputation documents const ipReputationDeleted = await this.cleanExpiredDocuments(CachedIPReputation, now);
const ipReputationDeleted = await this.cleanCollection(CachedIPReputation, now);
results.push({ collection: 'CachedIPReputation', deleted: ipReputationDeleted }); results.push({ collection: 'CachedIPReputation', deleted: ipReputationDeleted });
// Clean CachedBounce documents const bouncesDeleted = await this.cleanExpiredDocuments(CachedBounce, now);
const bouncesDeleted = await this.cleanCollection(CachedBounce, now);
results.push({ collection: 'CachedBounce', deleted: bouncesDeleted }); results.push({ collection: 'CachedBounce', deleted: bouncesDeleted });
// Clean CachedSuppression documents (but not permanent ones) const suppressionDeleted = await this.cleanExpiredDocuments(CachedSuppression, now);
const suppressionDeleted = await this.cleanCollection(CachedSuppression, now);
results.push({ collection: 'CachedSuppression', deleted: suppressionDeleted }); results.push({ collection: 'CachedSuppression', deleted: suppressionDeleted });
// Clean CachedDKIMKey documents const dkimDeleted = await this.cleanExpiredDocuments(CachedDKIMKey, now);
const dkimDeleted = await this.cleanCollection(CachedDKIMKey, now);
results.push({ collection: 'CachedDKIMKey', deleted: dkimDeleted }); results.push({ collection: 'CachedDKIMKey', deleted: dkimDeleted });
// Log results // Log results
@@ -137,17 +133,30 @@ export class CacheCleaner {
} }
/** /**
* Clean expired documents from a specific collection * Clean expired documents from a specific collection using smartdata API
*/ */
private async cleanCollection<T>( private async cleanExpiredDocuments<T extends { delete: () => Promise<void> }>(
documentClass: { deleteMany: (filter: any) => Promise<any> }, documentClass: { getInstances: (filter: any) => Promise<T[]> },
now: Date now: Date
): Promise<number> { ): Promise<number> {
try { try {
const result = await documentClass.deleteMany({ // Find all expired documents
const expiredDocs = await documentClass.getInstances({
expiresAt: { $lt: now }, expiresAt: { $lt: now },
}); });
return result?.deletedCount || 0;
// Delete each expired document
let deletedCount = 0;
for (const doc of expiredDocs) {
try {
await doc.delete();
deletedCount++;
} catch (deleteError) {
logger.log('warn', `Failed to delete expired document: ${deleteError.message}`);
}
}
return deletedCount;
} catch (error) { } catch (error) {
logger.log('error', `Error cleaning collection: ${error.message}`); logger.log('error', `Error cleaning collection: ${error.message}`);
return 0; return 0;

View File

@@ -7,24 +7,27 @@ import * as plugins from '../plugins.js';
* - Automatic timestamps (createdAt, lastAccessedAt) * - Automatic timestamps (createdAt, lastAccessedAt)
* - TTL/expiration support (expiresAt) * - TTL/expiration support (expiresAt)
* - Helper methods for TTL management * - Helper methods for TTL management
*
* NOTE: Subclasses MUST add @svDb() decorators to createdAt, expiresAt, and lastAccessedAt
* since decorators on abstract classes don't propagate correctly.
*/ */
export abstract class CachedDocument<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> { export abstract class CachedDocument<T extends CachedDocument<T>> extends plugins.smartdata.SmartDataDbDoc<T, T> {
/** /**
* Timestamp when the document was created * Timestamp when the document was created
* NOTE: Subclasses must add @svDb() decorator
*/ */
@plugins.smartdata.svDb()
public createdAt: Date = new Date(); public createdAt: Date = new Date();
/** /**
* Timestamp when the document expires and should be cleaned up * Timestamp when the document expires and should be cleaned up
* NOTE: Subclasses must add @svDb() decorator
*/ */
@plugins.smartdata.svDb()
public expiresAt: Date; public expiresAt: Date;
/** /**
* Timestamp of last access (for LRU-style eviction if needed) * Timestamp of last access (for LRU-style eviction if needed)
* NOTE: Subclasses must add @svDb() decorator
*/ */
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date(); public lastAccessedAt: Date = new Date();
/** /**

View File

@@ -69,19 +69,21 @@ export class CacheDb {
// Create LocalTsmDb instance // Create LocalTsmDb instance
this.localTsmDb = new plugins.smartmongo.LocalTsmDb({ this.localTsmDb = new plugins.smartmongo.LocalTsmDb({
dbDir: this.options.storagePath, folderPath: this.options.storagePath,
}); });
// Start LocalTsmDb and get connection URI // Start LocalTsmDb and get connection info
await this.localTsmDb.start(); const connectionInfo = await this.localTsmDb.start();
const mongoDescriptor = this.localTsmDb.mongoDescriptor;
if (this.options.debug) { if (this.options.debug) {
logger.log('debug', `LocalTsmDb started with descriptor: ${JSON.stringify(mongoDescriptor)}`); logger.log('debug', `LocalTsmDb started with URI: ${connectionInfo.connectionUri}`);
} }
// Initialize smartdata with the connection // Initialize smartdata with the connection URI
this.smartdataDb = new plugins.smartdata.SmartdataDb(mongoDescriptor); this.smartdataDb = new plugins.smartdata.SmartdataDb({
mongoDbUrl: connectionInfo.connectionUri,
mongoDbName: this.options.dbName,
});
await this.smartdataDb.init(); await this.smartdataDb.init();
this.isStarted = true; this.isStarted = true;

View File

@@ -33,6 +33,16 @@ export type TBounceCategory =
*/ */
@plugins.smartdata.Collection(() => getDb()) @plugins.smartdata.Collection(() => getDb())
export class CachedBounce extends CachedDocument<CachedBounce> { export class CachedBounce extends CachedDocument<CachedBounce> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
/** /**
* Unique identifier for this bounce record * Unique identifier for this bounce record
*/ */

View File

@@ -15,6 +15,16 @@ const getDb = () => CacheDb.getInstance().getDb();
*/ */
@plugins.smartdata.Collection(() => getDb()) @plugins.smartdata.Collection(() => getDb())
export class CachedDKIMKey extends CachedDocument<CachedDKIMKey> { export class CachedDKIMKey extends CachedDocument<CachedDKIMKey> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_90);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
/** /**
* Composite key: domain:selector * Composite key: domain:selector
*/ */

View File

@@ -20,6 +20,16 @@ const getDb = () => CacheDb.getInstance().getDb();
*/ */
@plugins.smartdata.Collection(() => getDb()) @plugins.smartdata.Collection(() => getDb())
export class CachedEmail extends CachedDocument<CachedEmail> { export class CachedEmail extends CachedDocument<CachedEmail> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
/** /**
* Unique identifier for this email * Unique identifier for this email
*/ */

View File

@@ -30,6 +30,16 @@ export interface IIPReputationData {
*/ */
@plugins.smartdata.Collection(() => getDb()) @plugins.smartdata.Collection(() => getDb())
export class CachedIPReputation extends CachedDocument<CachedIPReputation> { export class CachedIPReputation extends CachedDocument<CachedIPReputation> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.HOURS_24);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
/** /**
* IP address (unique identifier) * IP address (unique identifier)
*/ */

View File

@@ -27,6 +27,16 @@ export type TSuppressionReason =
*/ */
@plugins.smartdata.Collection(() => getDb()) @plugins.smartdata.Collection(() => getDb())
export class CachedSuppression extends CachedDocument<CachedSuppression> { export class CachedSuppression extends CachedDocument<CachedSuppression> {
// TTL fields from base class (decorators required on concrete class)
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
@plugins.smartdata.svDb()
public expiresAt: Date = new Date(Date.now() + TTL.DAYS_30);
@plugins.smartdata.svDb()
public lastAccessedAt: Date = new Date();
/** /**
* Email address to suppress (unique identifier) * Email address to suppress (unique identifier)
*/ */

View File

@@ -11,6 +11,8 @@ import { logger } from './logger.js';
import { configureEmailStorage, configureEmailServer } from './mail/delivery/index.js'; import { configureEmailStorage, configureEmailServer } from './mail/delivery/index.js';
// Import storage manager // Import storage manager
import { StorageManager, type IStorageConfig } from './storage/index.js'; import { StorageManager, type IStorageConfig } from './storage/index.js';
// Import cache system
import { CacheDb, CacheCleaner, type ICacheDbOptions } from './cache/index.js';
import { OpsServer } from './opsserver/index.js'; import { OpsServer } from './opsserver/index.js';
import { MetricsManager } from './monitoring/index.js'; import { MetricsManager } from './monitoring/index.js';
@@ -111,6 +113,36 @@ export interface IDcRouterOptions {
/** Storage configuration */ /** Storage configuration */
storage?: IStorageConfig; storage?: IStorageConfig;
/**
* Cache database configuration using smartdata and LocalTsmDb
* Provides persistent caching for emails, IP reputation, bounces, etc.
*/
cacheConfig?: {
/** Enable cache database (default: true) */
enabled?: boolean;
/** Storage path for TsmDB data (default: /etc/dcrouter/tsmdb) */
storagePath?: string;
/** Database name (default: dcrouter) */
dbName?: string;
/** Default TTL in days for cached items (default: 30) */
defaultTTLDays?: number;
/** Cleanup interval in hours (default: 1) */
cleanupIntervalHours?: number;
/** TTL configuration per data type (in days) */
ttlConfig?: {
/** Email cache TTL (default: 30 days) */
emails?: number;
/** IP reputation cache TTL (default: 1 day) */
ipReputation?: number;
/** Bounce records TTL (default: 30 days) */
bounces?: number;
/** DKIM keys TTL (default: 90 days) */
dkimKeys?: number;
/** Suppression list TTL (default: 30 days, can be permanent) */
suppression?: number;
};
};
/** /**
* RADIUS server configuration for network authentication * RADIUS server configuration for network authentication
* Enables MAC Authentication Bypass (MAB) and VLAN assignment * Enables MAC Authentication Bypass (MAB) and VLAN assignment
@@ -144,6 +176,10 @@ export class DcRouter {
public opsServer: OpsServer; public opsServer: OpsServer;
public metricsManager?: MetricsManager; public metricsManager?: MetricsManager;
// Cache system (smartdata + LocalTsmDb)
public cacheDb?: CacheDb;
public cacheCleaner?: CacheCleaner;
// TypedRouter for API endpoints // TypedRouter for API endpoints
public typedrouter = new plugins.typedrequest.TypedRouter(); public typedrouter = new plugins.typedrequest.TypedRouter();
@@ -170,6 +206,11 @@ export class DcRouter {
await this.opsServer.start(); await this.opsServer.start();
try { try {
// Initialize cache database if enabled (default: enabled)
if (this.options.cacheConfig?.enabled !== false) {
await this.setupCacheDb();
}
// Initialize MetricsManager // Initialize MetricsManager
this.metricsManager = new MetricsManager(this); this.metricsManager = new MetricsManager(this);
await this.metricsManager.start(); await this.metricsManager.start();
@@ -291,9 +332,45 @@ export class DcRouter {
console.log(` └─ Path: ${this.options.storage.fsPath || 'default'}`); console.log(` └─ Path: ${this.options.storage.fsPath || 'default'}`);
} }
// Cache database summary
if (this.cacheDb) {
console.log('\n🗄 Cache Database (smartdata + LocalTsmDb):');
console.log(` ├─ Storage: ${this.cacheDb.getStoragePath()}`);
console.log(` ├─ Database: ${this.cacheDb.getDbName()}`);
console.log(` └─ Cleaner: ${this.cacheCleaner?.isActive() ? 'Active' : 'Inactive'} (${(this.options.cacheConfig?.cleanupIntervalHours || 1)}h interval)`);
}
console.log('\n✅ All services are running\n'); console.log('\n✅ All services are running\n');
} }
/**
* Set up the cache database (smartdata + LocalTsmDb)
*/
private async setupCacheDb(): Promise<void> {
logger.log('info', 'Setting up CacheDb...');
const cacheConfig = this.options.cacheConfig || {};
// Initialize CacheDb singleton
this.cacheDb = CacheDb.getInstance({
storagePath: cacheConfig.storagePath || '/etc/dcrouter/tsmdb',
dbName: cacheConfig.dbName || 'dcrouter',
debug: false,
});
await this.cacheDb.start();
// Start the cache cleaner
const cleanupIntervalMs = (cacheConfig.cleanupIntervalHours || 1) * 60 * 60 * 1000;
this.cacheCleaner = new CacheCleaner(this.cacheDb, {
intervalMs: cleanupIntervalMs,
verbose: false,
});
this.cacheCleaner.start();
logger.log('info', `CacheDb initialized at ${this.cacheDb.getStoragePath()}`);
}
/** /**
* Set up SmartProxy with direct configuration and automatic email routes * Set up SmartProxy with direct configuration and automatic email routes
*/ */
@@ -604,6 +681,9 @@ export class DcRouter {
try { try {
// Stop all services in parallel for faster shutdown // Stop all services in parallel for faster shutdown
await Promise.all([ await Promise.all([
// Stop cache cleaner if running
this.cacheCleaner ? Promise.resolve(this.cacheCleaner.stop()) : Promise.resolve(),
// Stop metrics manager if running // Stop metrics manager if running
this.metricsManager ? this.metricsManager.stop().catch(err => console.error('Error stopping MetricsManager:', err)) : Promise.resolve(), this.metricsManager ? this.metricsManager.stop().catch(err => console.error('Error stopping MetricsManager:', err)) : Promise.resolve(),
@@ -624,6 +704,11 @@ export class DcRouter {
Promise.resolve() Promise.resolve()
]); ]);
// Stop cache database after other services (they may need it during shutdown)
if (this.cacheDb) {
await this.cacheDb.stop().catch(err => console.error('Error stopping CacheDb:', err));
}
console.log('All DcRouter services stopped'); console.log('All DcRouter services stopped');
} catch (error) { } catch (error) {
console.error('Error during DcRouter shutdown:', error); console.error('Error during DcRouter shutdown:', error);

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '4.0.0', version: '4.1.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -1,8 +1,5 @@
{ {
"compilerOptions": { "compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"useDefineForClassFields": false,
"target": "ES2022", "target": "ES2022",
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",