refactor: add & use ConfigFilePersister primitive (#1534)

Add `ConfigFilePersister<T>` to provide automatic JSON file persistence
for configs. It bridges the gap between in-memory configuration (via
`ConfigService`) and persistent file storage, with minimal developer
effort.

## Key Features

- **Reactive Persistence**: Automatically saves config changes to disk
when `ConfigService` updates
- **NestJS Integration**: Implements lifecycle hooks for proper
initialization and cleanup
- **Standalone Operations**: Provides direct file access via
`getFileHandler()` for non-reactive use cases
- **Change Detection**: Only writes to disk when configuration actually
changes (performance optimization)
- **Error Handling**: Includes logging and graceful error handling
throughout

## How to Implement

Extend the class and implement these required methods:

```typescript
@Injectable()
class MyConfigPersister extends ConfigFilePersister<MyConfigType> {
  constructor(configService: ConfigService) {
    super(configService);
  }

  // Required: JSON filename in config directory
  fileName(): string { 
    return "my-config.json"; 
  }

  // Required: ConfigService key for reactive updates
  configKey(): string { 
    return "myConfig"; 
  }

  // Required: Default values for new installations
  defaultConfig(): MyConfigType {
    return { enabled: false, timeout: 5000 };
  }

  // optionally, override validate() and/or migrateConfig()
}
```

## Lifecycle Behavior

- **Initialization** (`onModuleInit`): Loads config from disk → sets in
ConfigService → starts reactive subscription
- **Runtime**: Automatically persists to disk when ConfigService changes
(buffered every 25ms)
- **Shutdown** (`onModuleDestroy`): Final persistence and cleanup

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

* **New Features**
* Introduced a unified, robust configuration file management system with
automatic migration, validation, and efficient persistence for plugins
and services.

* **Refactor**
* Centralized configuration persistence logic into a shared base class,
simplifying and standardizing config handling.
* Refactored multiple config persisters to extend the new base class,
removing redundant manual file and lifecycle management.
* Removed legacy config state management, persistence helpers, and
related modules, streamlining the codebase.
* Simplified test suites to focus on core functionality and error
handling.

* **Chores**
* Updated dependencies to support the new configuration management
system.
* Integrated the new API config module into plugin modules for
consistent configuration handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Pujit Mehrotra
2025-07-23 13:34:12 -04:00
committed by GitHub
parent b6acf50c0d
commit fee7d4613e
27 changed files with 1732 additions and 1265 deletions

View File

@@ -1,81 +1,25 @@
import { Logger, Injectable, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { existsSync, readFileSync } from "fs";
import { writeFile } from "fs/promises";
import path from "path";
import { bufferTime } from "rxjs/operators";
import { Injectable } from "@nestjs/common";
import { ConfigFilePersister } from "@unraid/shared/services/config-file.js"; // npm install @unraid/shared
import { PluginNameConfig } from "./config.entity.js";
import { ConfigService } from "@nestjs/config";
@Injectable()
export class PluginNameConfigPersister implements OnModuleInit {
constructor(private readonly configService: ConfigService) {}
private logger = new Logger(PluginNameConfigPersister.name);
/** the file path to the config file for this plugin */
get configPath() {
return path.join(
this.configService.get("PATHS_CONFIG_MODULES")!,
"plugin-name.json" // Use kebab-case for the filename
);
export class PluginNameConfigPersister extends ConfigFilePersister<PluginNameConfig> {
constructor(configService: ConfigService) {
super(configService);
}
onModuleInit() {
this.logger.debug(`Config path: ${this.configPath}`);
// Load the config from the file if it exists, otherwise initialize it with defaults.
if (existsSync(this.configPath)) {
try {
const configFromFile = JSON.parse(
readFileSync(this.configPath, "utf8")
);
this.configService.set("plugin-name", configFromFile);
this.logger.verbose(`Config loaded from ${this.configPath}`);
} catch (error) {
this.logger.error(
`Error reading or parsing config file at ${this.configPath}. Using defaults.`,
error
);
// If loading fails, ensure default config is set and persisted
this.persist();
}
} else {
this.logger.log(
`Config file ${this.configPath} does not exist. Writing default config...`
);
// Persist the default configuration provided by configFeature
this.persist();
}
// Automatically persist changes to the config file after a short delay.
this.configService.changes$.pipe(bufferTime(25)).subscribe({
next: async (changes) => {
const pluginNameConfigChanged = changes.some(({ path }) =>
path.startsWith("plugin-name.")
);
if (pluginNameConfigChanged) {
this.logger.verbose("Plugin config changed");
await this.persist();
}
},
error: (err) => {
this.logger.error("Error receiving config changes:", err);
},
});
fileName(): string {
return "plugin-name.json"; // Use kebab-case for the filename
}
async persist(
config = this.configService.get<PluginNameConfig>("plugin-name")
) {
const data = JSON.stringify(config, null, 2);
this.logger.verbose(`Persisting config to ${this.configPath}: ${data}`);
try {
await writeFile(this.configPath, data);
this.logger.verbose(`Config change persisted to ${this.configPath}`);
} catch (error) {
this.logger.error(
`Error persisting config to '${this.configPath}':`,
error
);
}
configKey(): string {
return "plugin-name";
}
defaultConfig(): PluginNameConfig {
// Return the default configuration for your plugin
// This should match the structure defined in your config.entity.ts
return {} as PluginNameConfig;
}
}