feat(schema-manager): Add Schema Management module and expose it in public API; update README to mark Phase 3 complete and move priorities to Phase 4
This commit is contained in:
26
ts/domain/schema/index.ts
Normal file
26
ts/domain/schema/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Schema Management Module
|
||||
*
|
||||
* Index mapping management, templates, and migrations
|
||||
*/
|
||||
|
||||
// Main classes
|
||||
export { SchemaManager, createSchemaManager } from './schema-manager.js';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
FieldType,
|
||||
FieldDefinition,
|
||||
IndexSettings,
|
||||
IndexMapping,
|
||||
IndexSchema,
|
||||
SchemaMigration,
|
||||
MigrationStatus,
|
||||
MigrationHistoryEntry,
|
||||
SchemaManagerConfig,
|
||||
SchemaValidationResult,
|
||||
SchemaDiff,
|
||||
IndexTemplate,
|
||||
ComponentTemplate,
|
||||
SchemaManagerStats,
|
||||
} from './types.js';
|
||||
926
ts/domain/schema/schema-manager.ts
Normal file
926
ts/domain/schema/schema-manager.ts
Normal file
@@ -0,0 +1,926 @@
|
||||
/**
|
||||
* Schema Manager
|
||||
*
|
||||
* Index mapping management, templates, and migrations
|
||||
*/
|
||||
|
||||
import { ElasticsearchConnectionManager } from '../../core/connection/connection-manager.js';
|
||||
import { Logger, defaultLogger } from '../../core/observability/logger.js';
|
||||
import { MetricsCollector, defaultMetricsCollector } from '../../core/observability/metrics.js';
|
||||
import type {
|
||||
IndexSchema,
|
||||
IndexSettings,
|
||||
IndexMapping,
|
||||
FieldDefinition,
|
||||
SchemaMigration,
|
||||
MigrationHistoryEntry,
|
||||
MigrationStatus,
|
||||
SchemaManagerConfig,
|
||||
SchemaValidationResult,
|
||||
SchemaDiff,
|
||||
IndexTemplate,
|
||||
ComponentTemplate,
|
||||
SchemaManagerStats,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Default configuration
|
||||
*/
|
||||
const DEFAULT_CONFIG: Required<SchemaManagerConfig> = {
|
||||
historyIndex: '.schema_migrations',
|
||||
dryRun: false,
|
||||
strict: false,
|
||||
timeout: 60000, // 1 minute
|
||||
enableLogging: true,
|
||||
enableMetrics: true,
|
||||
validateBeforeApply: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Schema Manager
|
||||
*/
|
||||
export class SchemaManager {
|
||||
private config: Required<SchemaManagerConfig>;
|
||||
private stats: SchemaManagerStats;
|
||||
private logger: Logger;
|
||||
private metrics: MetricsCollector;
|
||||
|
||||
constructor(config: SchemaManagerConfig = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.logger = defaultLogger;
|
||||
this.metrics = defaultMetricsCollector;
|
||||
|
||||
this.stats = {
|
||||
totalMigrations: 0,
|
||||
successfulMigrations: 0,
|
||||
failedMigrations: 0,
|
||||
rolledBackMigrations: 0,
|
||||
totalIndices: 0,
|
||||
totalTemplates: 0,
|
||||
avgMigrationDuration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize schema manager
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
await this.ensureHistoryIndex();
|
||||
|
||||
this.logger.info('SchemaManager initialized', {
|
||||
historyIndex: this.config.historyIndex,
|
||||
dryRun: this.config.dryRun,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Index Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create an index with schema
|
||||
*/
|
||||
async createIndex(schema: IndexSchema): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (this.config.validateBeforeApply) {
|
||||
const validation = this.validateSchema(schema);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Schema validation failed: ${validation.errors.map((e) => e.message).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.dryRun) {
|
||||
this.logger.info('[DRY RUN] Would create index', { index: schema.name });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
||||
|
||||
const body: any = {
|
||||
settings: schema.settings,
|
||||
mappings: schema.mappings,
|
||||
};
|
||||
|
||||
if (schema.aliases) {
|
||||
body.aliases = schema.aliases;
|
||||
}
|
||||
|
||||
await client.indices.create({
|
||||
index: schema.name,
|
||||
...body,
|
||||
timeout: `${this.config.timeout}ms`,
|
||||
});
|
||||
|
||||
this.stats.totalIndices++;
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
this.logger.info('Index created', {
|
||||
index: schema.name,
|
||||
version: schema.version,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.config.enableMetrics) {
|
||||
this.metrics.recordCounter('schema.indices.created', 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update index mapping
|
||||
*/
|
||||
async updateMapping(index: string, mapping: Partial<IndexMapping>): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (this.config.dryRun) {
|
||||
this.logger.info('[DRY RUN] Would update mapping', { index });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
||||
|
||||
await client.indices.putMapping({
|
||||
index,
|
||||
...mapping,
|
||||
timeout: `${this.config.timeout}ms`,
|
||||
});
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
this.logger.info('Mapping updated', {
|
||||
index,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.config.enableMetrics) {
|
||||
this.metrics.recordCounter('schema.mappings.updated', 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update index settings
|
||||
*/
|
||||
async updateSettings(index: string, settings: Partial<IndexSettings>): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (this.config.dryRun) {
|
||||
this.logger.info('[DRY RUN] Would update settings', { index });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
||||
|
||||
// Some settings require closing the index
|
||||
const requiresClose = this.settingsRequireClose(settings);
|
||||
|
||||
if (requiresClose) {
|
||||
await client.indices.close({ index });
|
||||
}
|
||||
|
||||
try {
|
||||
await client.indices.putSettings({
|
||||
index,
|
||||
settings,
|
||||
timeout: `${this.config.timeout}ms`,
|
||||
});
|
||||
} finally {
|
||||
if (requiresClose) {
|
||||
await client.indices.open({ index });
|
||||
}
|
||||
}
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
this.logger.info('Settings updated', {
|
||||
index,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.config.enableMetrics) {
|
||||
this.metrics.recordCounter('schema.settings.updated', 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get index schema
|
||||
*/
|
||||
async getSchema(index: string): Promise<IndexSchema | null> {
|
||||
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
||||
|
||||
try {
|
||||
const [mappingResult, settingsResult, aliasResult] = await Promise.all([
|
||||
client.indices.getMapping({ index }),
|
||||
client.indices.getSettings({ index }),
|
||||
client.indices.getAlias({ index }),
|
||||
]);
|
||||
|
||||
const indexData = mappingResult[index];
|
||||
const settingsData = settingsResult[index];
|
||||
const aliasData = aliasResult[index];
|
||||
|
||||
return {
|
||||
name: index,
|
||||
version: 0, // Version not stored in ES by default
|
||||
settings: settingsData?.settings?.index as IndexSettings,
|
||||
mappings: indexData?.mappings as IndexMapping,
|
||||
aliases: aliasData?.aliases,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error.meta?.statusCode === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an index
|
||||
*/
|
||||
async deleteIndex(index: string): Promise<void> {
|
||||
if (this.config.dryRun) {
|
||||
this.logger.info('[DRY RUN] Would delete index', { index });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
||||
|
||||
await client.indices.delete({
|
||||
index,
|
||||
timeout: `${this.config.timeout}ms`,
|
||||
});
|
||||
|
||||
this.stats.totalIndices--;
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
this.logger.info('Index deleted', { index });
|
||||
}
|
||||
|
||||
if (this.config.enableMetrics) {
|
||||
this.metrics.recordCounter('schema.indices.deleted', 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if index exists
|
||||
*/
|
||||
async indexExists(index: string): Promise<boolean> {
|
||||
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
||||
return await client.indices.exists({ index });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Migration Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Run migrations
|
||||
*/
|
||||
async migrate(migrations: SchemaMigration[]): Promise<MigrationHistoryEntry[]> {
|
||||
const results: MigrationHistoryEntry[] = [];
|
||||
|
||||
// Get current migration state
|
||||
const appliedVersions = await this.getAppliedMigrations();
|
||||
const appliedSet = new Set(appliedVersions.map((m) => m.version));
|
||||
|
||||
// Sort migrations by version
|
||||
const sortedMigrations = [...migrations].sort((a, b) => a.version - b.version);
|
||||
|
||||
// Filter pending migrations
|
||||
const pendingMigrations = sortedMigrations.filter((m) => !appliedSet.has(m.version));
|
||||
|
||||
if (pendingMigrations.length === 0) {
|
||||
this.logger.info('No pending migrations');
|
||||
return results;
|
||||
}
|
||||
|
||||
this.logger.info(`Running ${pendingMigrations.length} migrations`);
|
||||
|
||||
for (const migration of pendingMigrations) {
|
||||
const result = await this.runMigration(migration);
|
||||
results.push(result);
|
||||
|
||||
if (result.status === 'failed') {
|
||||
this.logger.error('Migration failed, stopping', {
|
||||
version: migration.version,
|
||||
name: migration.name,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single migration
|
||||
*/
|
||||
private async runMigration(migration: SchemaMigration): Promise<MigrationHistoryEntry> {
|
||||
const startTime = Date.now();
|
||||
const entry: MigrationHistoryEntry = {
|
||||
version: migration.version,
|
||||
name: migration.name,
|
||||
status: 'running',
|
||||
startedAt: new Date(),
|
||||
};
|
||||
|
||||
this.stats.totalMigrations++;
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
this.logger.info('Running migration', {
|
||||
version: migration.version,
|
||||
name: migration.name,
|
||||
type: migration.type,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await this.applyMigration(migration);
|
||||
|
||||
entry.status = 'completed';
|
||||
entry.completedAt = new Date();
|
||||
entry.duration = Date.now() - startTime;
|
||||
|
||||
this.stats.successfulMigrations++;
|
||||
this.stats.lastMigrationTime = new Date();
|
||||
this.updateAvgDuration(entry.duration);
|
||||
|
||||
// Record in history
|
||||
await this.recordMigration(entry);
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
this.logger.info('Migration completed', {
|
||||
version: migration.version,
|
||||
name: migration.name,
|
||||
duration: entry.duration,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.config.enableMetrics) {
|
||||
this.metrics.recordCounter('schema.migrations.success', 1);
|
||||
this.metrics.recordHistogram('schema.migrations.duration', entry.duration);
|
||||
}
|
||||
} catch (error: any) {
|
||||
entry.status = 'failed';
|
||||
entry.completedAt = new Date();
|
||||
entry.duration = Date.now() - startTime;
|
||||
entry.error = error.message;
|
||||
|
||||
this.stats.failedMigrations++;
|
||||
|
||||
// Record failure in history
|
||||
await this.recordMigration(entry);
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
this.logger.error('Migration failed', {
|
||||
version: migration.version,
|
||||
name: migration.name,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.config.enableMetrics) {
|
||||
this.metrics.recordCounter('schema.migrations.failed', 1);
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply migration changes
|
||||
*/
|
||||
private async applyMigration(migration: SchemaMigration): Promise<void> {
|
||||
if (this.config.dryRun) {
|
||||
this.logger.info('[DRY RUN] Would apply migration', {
|
||||
version: migration.version,
|
||||
name: migration.name,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
||||
|
||||
switch (migration.type) {
|
||||
case 'create':
|
||||
await this.createIndex({
|
||||
name: migration.index,
|
||||
version: migration.version,
|
||||
settings: migration.changes.settings,
|
||||
mappings: migration.changes.mappings as IndexMapping,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
if (migration.changes.settings) {
|
||||
await this.updateSettings(migration.index, migration.changes.settings);
|
||||
}
|
||||
if (migration.changes.mappings) {
|
||||
await this.updateMapping(migration.index, migration.changes.mappings);
|
||||
}
|
||||
if (migration.changes.aliases) {
|
||||
await this.updateAliases(migration.index, migration.changes.aliases);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
await this.deleteIndex(migration.index);
|
||||
break;
|
||||
|
||||
case 'reindex':
|
||||
if (migration.changes.reindex) {
|
||||
await client.reindex({
|
||||
source: { index: migration.changes.reindex.source },
|
||||
dest: { index: migration.changes.reindex.dest },
|
||||
script: migration.changes.reindex.script
|
||||
? { source: migration.changes.reindex.script }
|
||||
: undefined,
|
||||
timeout: `${this.config.timeout}ms`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'alias':
|
||||
if (migration.changes.aliases) {
|
||||
await this.updateAliases(migration.index, migration.changes.aliases);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback a migration
|
||||
*/
|
||||
async rollback(version: number, migrations: SchemaMigration[]): Promise<MigrationHistoryEntry> {
|
||||
const migration = migrations.find((m) => m.version === version);
|
||||
|
||||
if (!migration) {
|
||||
throw new Error(`Migration version ${version} not found`);
|
||||
}
|
||||
|
||||
if (!migration.rollback) {
|
||||
throw new Error(`Migration version ${version} has no rollback defined`);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const entry: MigrationHistoryEntry = {
|
||||
version,
|
||||
name: `rollback_${migration.name}`,
|
||||
status: 'running',
|
||||
startedAt: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
// Apply rollback changes
|
||||
if (migration.rollback.settings) {
|
||||
await this.updateSettings(migration.index, migration.rollback.settings);
|
||||
}
|
||||
if (migration.rollback.mappings) {
|
||||
await this.updateMapping(migration.index, migration.rollback.mappings);
|
||||
}
|
||||
|
||||
entry.status = 'rolled_back';
|
||||
entry.completedAt = new Date();
|
||||
entry.duration = Date.now() - startTime;
|
||||
|
||||
this.stats.rolledBackMigrations++;
|
||||
|
||||
// Update history
|
||||
await this.recordMigration(entry);
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
this.logger.info('Migration rolled back', {
|
||||
version,
|
||||
name: migration.name,
|
||||
duration: entry.duration,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.config.enableMetrics) {
|
||||
this.metrics.recordCounter('schema.migrations.rollback', 1);
|
||||
}
|
||||
} catch (error: any) {
|
||||
entry.status = 'failed';
|
||||
entry.error = error.message;
|
||||
|
||||
this.logger.error('Rollback failed', {
|
||||
version,
|
||||
name: migration.name,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get applied migrations
|
||||
*/
|
||||
async getAppliedMigrations(): Promise<MigrationHistoryEntry[]> {
|
||||
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
||||
|
||||
try {
|
||||
const result = await client.search({
|
||||
index: this.config.historyIndex,
|
||||
size: 1000,
|
||||
sort: [{ version: 'asc' }],
|
||||
});
|
||||
|
||||
return result.hits.hits.map((hit) => hit._source as MigrationHistoryEntry);
|
||||
} catch (error: any) {
|
||||
if (error.meta?.statusCode === 404) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Template Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Create or update index template
|
||||
*/
|
||||
async putTemplate(template: IndexTemplate): Promise<void> {
|
||||
if (this.config.dryRun) {
|
||||
this.logger.info('[DRY RUN] Would put template', { name: template.name });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
||||
|
||||
await client.indices.putIndexTemplate({
|
||||
name: template.name,
|
||||
index_patterns: template.index_patterns,
|
||||
priority: template.priority,
|
||||
version: template.version,
|
||||
composed_of: template.composed_of,
|
||||
template: template.template,
|
||||
data_stream: template.data_stream,
|
||||
_meta: template._meta,
|
||||
});
|
||||
|
||||
this.stats.totalTemplates++;
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
this.logger.info('Template created/updated', { name: template.name });
|
||||
}
|
||||
|
||||
if (this.config.enableMetrics) {
|
||||
this.metrics.recordCounter('schema.templates.created', 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get index template
|
||||
*/
|
||||
async getTemplate(name: string): Promise<IndexTemplate | null> {
|
||||
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
||||
|
||||
try {
|
||||
const result = await client.indices.getIndexTemplate({ name });
|
||||
const template = result.index_templates[0];
|
||||
|
||||
if (!template) return null;
|
||||
|
||||
return {
|
||||
name: template.name,
|
||||
index_patterns: template.index_template.index_patterns,
|
||||
priority: template.index_template.priority,
|
||||
version: template.index_template.version,
|
||||
composed_of: template.index_template.composed_of,
|
||||
template: template.index_template.template,
|
||||
data_stream: template.index_template.data_stream,
|
||||
_meta: template.index_template._meta,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error.meta?.statusCode === 404) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete index template
|
||||
*/
|
||||
async deleteTemplate(name: string): Promise<void> {
|
||||
if (this.config.dryRun) {
|
||||
this.logger.info('[DRY RUN] Would delete template', { name });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
||||
|
||||
await client.indices.deleteIndexTemplate({ name });
|
||||
|
||||
this.stats.totalTemplates--;
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
this.logger.info('Template deleted', { name });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update component template
|
||||
*/
|
||||
async putComponentTemplate(template: ComponentTemplate): Promise<void> {
|
||||
if (this.config.dryRun) {
|
||||
this.logger.info('[DRY RUN] Would put component template', { name: template.name });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
||||
|
||||
await client.cluster.putComponentTemplate({
|
||||
name: template.name,
|
||||
template: template.template,
|
||||
version: template.version,
|
||||
_meta: template._meta,
|
||||
});
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
this.logger.info('Component template created/updated', { name: template.name });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Alias Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Update aliases
|
||||
*/
|
||||
async updateAliases(
|
||||
index: string,
|
||||
aliases: { add?: Record<string, unknown>; remove?: string[] }
|
||||
): Promise<void> {
|
||||
if (this.config.dryRun) {
|
||||
this.logger.info('[DRY RUN] Would update aliases', { index });
|
||||
return;
|
||||
}
|
||||
|
||||
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
||||
|
||||
const actions: any[] = [];
|
||||
|
||||
if (aliases.add) {
|
||||
for (const [alias, config] of Object.entries(aliases.add)) {
|
||||
actions.push({ add: { index, alias, ...config } });
|
||||
}
|
||||
}
|
||||
|
||||
if (aliases.remove) {
|
||||
for (const alias of aliases.remove) {
|
||||
actions.push({ remove: { index, alias } });
|
||||
}
|
||||
}
|
||||
|
||||
if (actions.length > 0) {
|
||||
await client.indices.updateAliases({ actions });
|
||||
}
|
||||
|
||||
if (this.config.enableLogging) {
|
||||
this.logger.info('Aliases updated', { index, actions: actions.length });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add alias
|
||||
*/
|
||||
async addAlias(index: string, alias: string, config: Record<string, unknown> = {}): Promise<void> {
|
||||
await this.updateAliases(index, { add: { [alias]: config } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove alias
|
||||
*/
|
||||
async removeAlias(index: string, alias: string): Promise<void> {
|
||||
await this.updateAliases(index, { remove: [alias] });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Schema Validation and Comparison
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Validate schema
|
||||
*/
|
||||
validateSchema(schema: IndexSchema): SchemaValidationResult {
|
||||
const errors: SchemaValidationResult['errors'] = [];
|
||||
const warnings: SchemaValidationResult['warnings'] = [];
|
||||
|
||||
// Validate index name
|
||||
if (!schema.name || schema.name.length === 0) {
|
||||
errors.push({ field: 'name', message: 'Index name is required', severity: 'error' });
|
||||
}
|
||||
|
||||
if (schema.name && schema.name.startsWith('_')) {
|
||||
errors.push({ field: 'name', message: 'Index name cannot start with _', severity: 'error' });
|
||||
}
|
||||
|
||||
// Validate mappings
|
||||
if (!schema.mappings || !schema.mappings.properties) {
|
||||
errors.push({ field: 'mappings', message: 'Mappings with properties are required', severity: 'error' });
|
||||
} else {
|
||||
this.validateFields(schema.mappings.properties, '', errors, warnings);
|
||||
}
|
||||
|
||||
// Validate settings
|
||||
if (schema.settings) {
|
||||
if (schema.settings.number_of_shards !== undefined && schema.settings.number_of_shards < 1) {
|
||||
errors.push({ field: 'settings.number_of_shards', message: 'Must be at least 1', severity: 'error' });
|
||||
}
|
||||
|
||||
if (schema.settings.number_of_replicas !== undefined && schema.settings.number_of_replicas < 0) {
|
||||
errors.push({ field: 'settings.number_of_replicas', message: 'Cannot be negative', severity: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two schemas
|
||||
*/
|
||||
diffSchemas(oldSchema: IndexSchema, newSchema: IndexSchema): SchemaDiff {
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const modified: SchemaDiff['modified'] = [];
|
||||
const breakingChanges: string[] = [];
|
||||
|
||||
// Compare fields
|
||||
const oldFields = this.flattenFields(oldSchema.mappings?.properties || {});
|
||||
const newFields = this.flattenFields(newSchema.mappings?.properties || {});
|
||||
|
||||
for (const field of newFields.keys()) {
|
||||
if (!oldFields.has(field)) {
|
||||
added.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of oldFields.keys()) {
|
||||
if (!newFields.has(field)) {
|
||||
removed.push(field);
|
||||
breakingChanges.push(`Removed field: ${field}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [field, newDef] of newFields) {
|
||||
const oldDef = oldFields.get(field);
|
||||
if (oldDef && JSON.stringify(oldDef) !== JSON.stringify(newDef)) {
|
||||
modified.push({ field, from: oldDef, to: newDef });
|
||||
|
||||
// Check for breaking changes
|
||||
if (oldDef.type !== newDef.type) {
|
||||
breakingChanges.push(`Field type changed: ${field} (${oldDef.type} -> ${newDef.type})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
identical: added.length === 0 && removed.length === 0 && modified.length === 0,
|
||||
added,
|
||||
removed,
|
||||
modified,
|
||||
settingsChanged: JSON.stringify(oldSchema.settings) !== JSON.stringify(newSchema.settings),
|
||||
aliasesChanged: JSON.stringify(oldSchema.aliases) !== JSON.stringify(newSchema.aliases),
|
||||
breakingChanges,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics
|
||||
*/
|
||||
getStats(): SchemaManagerStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Private Methods
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Ensure history index exists
|
||||
*/
|
||||
private async ensureHistoryIndex(): Promise<void> {
|
||||
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
||||
|
||||
const exists = await client.indices.exists({ index: this.config.historyIndex });
|
||||
|
||||
if (!exists) {
|
||||
await client.indices.create({
|
||||
index: this.config.historyIndex,
|
||||
mappings: {
|
||||
properties: {
|
||||
version: { type: 'integer' },
|
||||
name: { type: 'keyword' },
|
||||
status: { type: 'keyword' },
|
||||
startedAt: { type: 'date' },
|
||||
completedAt: { type: 'date' },
|
||||
duration: { type: 'long' },
|
||||
error: { type: 'text' },
|
||||
checksum: { type: 'keyword' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record migration in history
|
||||
*/
|
||||
private async recordMigration(entry: MigrationHistoryEntry): Promise<void> {
|
||||
const client = ElasticsearchConnectionManager.getInstance().getClient();
|
||||
|
||||
await client.index({
|
||||
index: this.config.historyIndex,
|
||||
id: `migration-${entry.version}`,
|
||||
document: entry,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if settings require closing the index
|
||||
*/
|
||||
private settingsRequireClose(settings: Partial<IndexSettings>): boolean {
|
||||
const closeRequired = ['number_of_shards', 'codec', 'routing_partition_size'];
|
||||
return Object.keys(settings).some((key) => closeRequired.includes(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate fields recursively
|
||||
*/
|
||||
private validateFields(
|
||||
fields: Record<string, FieldDefinition>,
|
||||
prefix: string,
|
||||
errors: SchemaValidationResult['errors'],
|
||||
warnings: SchemaValidationResult['warnings']
|
||||
): void {
|
||||
for (const [name, def] of Object.entries(fields)) {
|
||||
const path = prefix ? `${prefix}.${name}` : name;
|
||||
|
||||
if (!def.type) {
|
||||
errors.push({ field: path, message: 'Field type is required', severity: 'error' });
|
||||
}
|
||||
|
||||
// Check for text fields without keyword sub-field
|
||||
if (def.type === 'text' && !def.properties) {
|
||||
warnings.push({
|
||||
field: path,
|
||||
message: 'Text field without keyword sub-field may limit aggregation capabilities',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate nested properties
|
||||
if (def.properties) {
|
||||
this.validateFields(def.properties, path, errors, warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten nested fields
|
||||
*/
|
||||
private flattenFields(
|
||||
fields: Record<string, FieldDefinition>,
|
||||
prefix: string = ''
|
||||
): Map<string, FieldDefinition> {
|
||||
const result = new Map<string, FieldDefinition>();
|
||||
|
||||
for (const [name, def] of Object.entries(fields)) {
|
||||
const path = prefix ? `${prefix}.${name}` : name;
|
||||
result.set(path, def);
|
||||
|
||||
if (def.properties) {
|
||||
const nested = this.flattenFields(def.properties, path);
|
||||
for (const [nestedPath, nestedDef] of nested) {
|
||||
result.set(nestedPath, nestedDef);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update average migration duration
|
||||
*/
|
||||
private updateAvgDuration(duration: number): void {
|
||||
const total = this.stats.successfulMigrations + this.stats.failedMigrations;
|
||||
this.stats.avgMigrationDuration =
|
||||
(this.stats.avgMigrationDuration * (total - 1) + duration) / total;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a schema manager
|
||||
*/
|
||||
export function createSchemaManager(config?: SchemaManagerConfig): SchemaManager {
|
||||
return new SchemaManager(config);
|
||||
}
|
||||
512
ts/domain/schema/types.ts
Normal file
512
ts/domain/schema/types.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* Schema Management Types
|
||||
*
|
||||
* Index mapping management, templates, and migrations
|
||||
*/
|
||||
|
||||
/**
|
||||
* Elasticsearch field types
|
||||
*/
|
||||
export type FieldType =
|
||||
| 'text'
|
||||
| 'keyword'
|
||||
| 'long'
|
||||
| 'integer'
|
||||
| 'short'
|
||||
| 'byte'
|
||||
| 'double'
|
||||
| 'float'
|
||||
| 'half_float'
|
||||
| 'scaled_float'
|
||||
| 'date'
|
||||
| 'date_nanos'
|
||||
| 'boolean'
|
||||
| 'binary'
|
||||
| 'integer_range'
|
||||
| 'float_range'
|
||||
| 'long_range'
|
||||
| 'double_range'
|
||||
| 'date_range'
|
||||
| 'ip_range'
|
||||
| 'object'
|
||||
| 'nested'
|
||||
| 'geo_point'
|
||||
| 'geo_shape'
|
||||
| 'completion'
|
||||
| 'search_as_you_type'
|
||||
| 'token_count'
|
||||
| 'percolator'
|
||||
| 'join'
|
||||
| 'rank_feature'
|
||||
| 'rank_features'
|
||||
| 'dense_vector'
|
||||
| 'sparse_vector'
|
||||
| 'flattened'
|
||||
| 'shape'
|
||||
| 'histogram'
|
||||
| 'ip'
|
||||
| 'alias';
|
||||
|
||||
/**
|
||||
* Field definition
|
||||
*/
|
||||
export interface FieldDefinition {
|
||||
/** Field type */
|
||||
type: FieldType;
|
||||
|
||||
/** Index this field */
|
||||
index?: boolean;
|
||||
|
||||
/** Store this field */
|
||||
store?: boolean;
|
||||
|
||||
/** Enable doc values */
|
||||
doc_values?: boolean;
|
||||
|
||||
/** Analyzer for text fields */
|
||||
analyzer?: string;
|
||||
|
||||
/** Search analyzer */
|
||||
search_analyzer?: string;
|
||||
|
||||
/** Normalizer for keyword fields */
|
||||
normalizer?: string;
|
||||
|
||||
/** Boost factor */
|
||||
boost?: number;
|
||||
|
||||
/** Coerce values */
|
||||
coerce?: boolean;
|
||||
|
||||
/** Copy to other fields */
|
||||
copy_to?: string | string[];
|
||||
|
||||
/** Enable norms */
|
||||
norms?: boolean;
|
||||
|
||||
/** Null value replacement */
|
||||
null_value?: unknown;
|
||||
|
||||
/** Ignore values above this length */
|
||||
ignore_above?: number;
|
||||
|
||||
/** Date format */
|
||||
format?: string;
|
||||
|
||||
/** Scaling factor for scaled_float */
|
||||
scaling_factor?: number;
|
||||
|
||||
/** Nested properties */
|
||||
properties?: Record<string, FieldDefinition>;
|
||||
|
||||
/** Enable eager global ordinals */
|
||||
eager_global_ordinals?: boolean;
|
||||
|
||||
/** Similarity algorithm */
|
||||
similarity?: string;
|
||||
|
||||
/** Term vector */
|
||||
term_vector?: 'no' | 'yes' | 'with_positions' | 'with_offsets' | 'with_positions_offsets';
|
||||
|
||||
/** Dimensions for dense_vector */
|
||||
dims?: number;
|
||||
|
||||
/** Additional properties */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index settings
|
||||
*/
|
||||
export interface IndexSettings {
|
||||
/** Number of primary shards */
|
||||
number_of_shards?: number;
|
||||
|
||||
/** Number of replica shards */
|
||||
number_of_replicas?: number;
|
||||
|
||||
/** Refresh interval */
|
||||
refresh_interval?: string;
|
||||
|
||||
/** Maximum result window */
|
||||
max_result_window?: number;
|
||||
|
||||
/** Maximum inner result window */
|
||||
max_inner_result_window?: number;
|
||||
|
||||
/** Maximum rescore window */
|
||||
max_rescore_window?: number;
|
||||
|
||||
/** Maximum docvalue fields search */
|
||||
max_docvalue_fields_search?: number;
|
||||
|
||||
/** Maximum script fields */
|
||||
max_script_fields?: number;
|
||||
|
||||
/** Maximum regex length */
|
||||
max_regex_length?: number;
|
||||
|
||||
/** Codec */
|
||||
codec?: string;
|
||||
|
||||
/** Routing partition size */
|
||||
routing_partition_size?: number;
|
||||
|
||||
/** Soft deletes retention period */
|
||||
soft_deletes?: {
|
||||
retention_lease?: {
|
||||
period?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** Analysis settings */
|
||||
analysis?: {
|
||||
analyzer?: Record<string, unknown>;
|
||||
tokenizer?: Record<string, unknown>;
|
||||
filter?: Record<string, unknown>;
|
||||
char_filter?: Record<string, unknown>;
|
||||
normalizer?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Additional settings */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index mapping
|
||||
*/
|
||||
export interface IndexMapping {
|
||||
/** Dynamic mapping behavior */
|
||||
dynamic?: boolean | 'strict' | 'runtime';
|
||||
|
||||
/** Date detection */
|
||||
date_detection?: boolean;
|
||||
|
||||
/** Dynamic date formats */
|
||||
dynamic_date_formats?: string[];
|
||||
|
||||
/** Numeric detection */
|
||||
numeric_detection?: boolean;
|
||||
|
||||
/** Field properties */
|
||||
properties: Record<string, FieldDefinition>;
|
||||
|
||||
/** Runtime fields */
|
||||
runtime?: Record<string, {
|
||||
type: string;
|
||||
script?: {
|
||||
source: string;
|
||||
lang?: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
/** Meta fields */
|
||||
_source?: {
|
||||
enabled?: boolean;
|
||||
includes?: string[];
|
||||
excludes?: string[];
|
||||
};
|
||||
|
||||
_routing?: {
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index schema definition
|
||||
*/
|
||||
export interface IndexSchema {
|
||||
/** Index name or pattern */
|
||||
name: string;
|
||||
|
||||
/** Schema version */
|
||||
version: number;
|
||||
|
||||
/** Index settings */
|
||||
settings?: IndexSettings;
|
||||
|
||||
/** Index mapping */
|
||||
mappings: IndexMapping;
|
||||
|
||||
/** Index aliases */
|
||||
aliases?: Record<string, {
|
||||
filter?: unknown;
|
||||
routing?: string;
|
||||
is_write_index?: boolean;
|
||||
is_hidden?: boolean;
|
||||
}>;
|
||||
|
||||
/** Lifecycle policy */
|
||||
lifecycle?: {
|
||||
name: string;
|
||||
rollover_alias?: string;
|
||||
};
|
||||
|
||||
/** Schema metadata */
|
||||
metadata?: {
|
||||
description?: string;
|
||||
owner?: string;
|
||||
created?: Date;
|
||||
updated?: Date;
|
||||
tags?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema migration
|
||||
*/
|
||||
export interface SchemaMigration {
|
||||
/** Migration version (must be unique) */
|
||||
version: number;
|
||||
|
||||
/** Migration name */
|
||||
name: string;
|
||||
|
||||
/** Migration description */
|
||||
description?: string;
|
||||
|
||||
/** Migration type */
|
||||
type: 'create' | 'update' | 'delete' | 'reindex' | 'alias';
|
||||
|
||||
/** Target index */
|
||||
index: string;
|
||||
|
||||
/** Changes to apply */
|
||||
changes: {
|
||||
/** Settings changes */
|
||||
settings?: Partial<IndexSettings>;
|
||||
|
||||
/** Mapping changes (new or modified fields) */
|
||||
mappings?: {
|
||||
properties?: Record<string, FieldDefinition>;
|
||||
dynamic?: boolean | 'strict' | 'runtime';
|
||||
};
|
||||
|
||||
/** Alias changes */
|
||||
aliases?: {
|
||||
add?: Record<string, unknown>;
|
||||
remove?: string[];
|
||||
};
|
||||
|
||||
/** Reindex configuration */
|
||||
reindex?: {
|
||||
source: string;
|
||||
dest: string;
|
||||
script?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** Rollback changes */
|
||||
rollback?: {
|
||||
settings?: Partial<IndexSettings>;
|
||||
mappings?: {
|
||||
properties?: Record<string, FieldDefinition>;
|
||||
};
|
||||
};
|
||||
|
||||
/** Migration metadata */
|
||||
metadata?: {
|
||||
author?: string;
|
||||
created?: Date;
|
||||
ticket?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Migration status
|
||||
*/
|
||||
export type MigrationStatus = 'pending' | 'running' | 'completed' | 'failed' | 'rolled_back';
|
||||
|
||||
/**
|
||||
* Migration history entry
|
||||
*/
|
||||
export interface MigrationHistoryEntry {
|
||||
/** Migration version */
|
||||
version: number;
|
||||
|
||||
/** Migration name */
|
||||
name: string;
|
||||
|
||||
/** Migration status */
|
||||
status: MigrationStatus;
|
||||
|
||||
/** Started at */
|
||||
startedAt: Date;
|
||||
|
||||
/** Completed at */
|
||||
completedAt?: Date;
|
||||
|
||||
/** Duration in milliseconds */
|
||||
duration?: number;
|
||||
|
||||
/** Error if failed */
|
||||
error?: string;
|
||||
|
||||
/** Checksum of migration */
|
||||
checksum?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema manager configuration
|
||||
*/
|
||||
export interface SchemaManagerConfig {
|
||||
/** Index for storing migration history */
|
||||
historyIndex?: string;
|
||||
|
||||
/** Enable dry run mode */
|
||||
dryRun?: boolean;
|
||||
|
||||
/** Enable strict mode (fail on warnings) */
|
||||
strict?: boolean;
|
||||
|
||||
/** Timeout for operations */
|
||||
timeout?: number;
|
||||
|
||||
/** Enable logging */
|
||||
enableLogging?: boolean;
|
||||
|
||||
/** Enable metrics */
|
||||
enableMetrics?: boolean;
|
||||
|
||||
/** Validate schemas before applying */
|
||||
validateBeforeApply?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema validation result
|
||||
*/
|
||||
export interface SchemaValidationResult {
|
||||
/** Whether schema is valid */
|
||||
valid: boolean;
|
||||
|
||||
/** Validation errors */
|
||||
errors: Array<{
|
||||
field: string;
|
||||
message: string;
|
||||
severity: 'error' | 'warning';
|
||||
}>;
|
||||
|
||||
/** Validation warnings */
|
||||
warnings: Array<{
|
||||
field: string;
|
||||
message: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema diff result
|
||||
*/
|
||||
export interface SchemaDiff {
|
||||
/** Whether schemas are identical */
|
||||
identical: boolean;
|
||||
|
||||
/** Added fields */
|
||||
added: string[];
|
||||
|
||||
/** Removed fields */
|
||||
removed: string[];
|
||||
|
||||
/** Modified fields */
|
||||
modified: Array<{
|
||||
field: string;
|
||||
from: FieldDefinition;
|
||||
to: FieldDefinition;
|
||||
}>;
|
||||
|
||||
/** Settings changes */
|
||||
settingsChanged: boolean;
|
||||
|
||||
/** Aliases changes */
|
||||
aliasesChanged: boolean;
|
||||
|
||||
/** Breaking changes */
|
||||
breakingChanges: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Template definition
|
||||
*/
|
||||
export interface IndexTemplate {
|
||||
/** Template name */
|
||||
name: string;
|
||||
|
||||
/** Index patterns */
|
||||
index_patterns: string[];
|
||||
|
||||
/** Template priority */
|
||||
priority?: number;
|
||||
|
||||
/** Template version */
|
||||
version?: number;
|
||||
|
||||
/** Composed of component templates */
|
||||
composed_of?: string[];
|
||||
|
||||
/** Template settings */
|
||||
template?: {
|
||||
settings?: IndexSettings;
|
||||
mappings?: IndexMapping;
|
||||
aliases?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Data stream settings */
|
||||
data_stream?: {
|
||||
hidden?: boolean;
|
||||
allow_custom_routing?: boolean;
|
||||
};
|
||||
|
||||
/** Template metadata */
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component template
|
||||
*/
|
||||
export interface ComponentTemplate {
|
||||
/** Template name */
|
||||
name: string;
|
||||
|
||||
/** Template content */
|
||||
template: {
|
||||
settings?: IndexSettings;
|
||||
mappings?: IndexMapping;
|
||||
aliases?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/** Template version */
|
||||
version?: number;
|
||||
|
||||
/** Template metadata */
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema manager statistics
|
||||
*/
|
||||
export interface SchemaManagerStats {
|
||||
/** Total migrations applied */
|
||||
totalMigrations: number;
|
||||
|
||||
/** Successful migrations */
|
||||
successfulMigrations: number;
|
||||
|
||||
/** Failed migrations */
|
||||
failedMigrations: number;
|
||||
|
||||
/** Rolled back migrations */
|
||||
rolledBackMigrations: number;
|
||||
|
||||
/** Total indices managed */
|
||||
totalIndices: number;
|
||||
|
||||
/** Total templates managed */
|
||||
totalTemplates: number;
|
||||
|
||||
/** Last migration time */
|
||||
lastMigrationTime?: Date;
|
||||
|
||||
/** Average migration duration */
|
||||
avgMigrationDuration: number;
|
||||
}
|
||||
Reference in New Issue
Block a user