From 8dcd853bd56197406cf6a012ca6e6218dc453444 Mon Sep 17 00:00:00 2001 From: Sadeequ <70214653+Sadeequ@users.noreply.github.com> Date: Tue, 30 Jun 2026 05:54:40 +0000 Subject: [PATCH] feat: Implemented soroban Analysis Artifact with full synchrony --- src/storage/artifacts/stellar/README.md | 374 +++++++++++ .../__tests__/artifact-storage.spec.ts | 582 ++++++++++++++++++ .../artifacts/stellar/artifact-retriever.ts | 347 +++++++++++ .../stellar/artifact-storage.service.ts | 548 +++++++++++++++++ src/storage/artifacts/stellar/index.ts | 44 ++ .../artifacts/stellar/metadata-manager.ts | 501 +++++++++++++++ src/storage/artifacts/stellar/types.ts | 192 ++++++ 7 files changed, 2588 insertions(+) create mode 100644 src/storage/artifacts/stellar/README.md create mode 100644 src/storage/artifacts/stellar/__tests__/artifact-storage.spec.ts create mode 100644 src/storage/artifacts/stellar/artifact-retriever.ts create mode 100644 src/storage/artifacts/stellar/artifact-storage.service.ts create mode 100644 src/storage/artifacts/stellar/index.ts create mode 100644 src/storage/artifacts/stellar/metadata-manager.ts create mode 100644 src/storage/artifacts/stellar/types.ts diff --git a/src/storage/artifacts/stellar/README.md b/src/storage/artifacts/stellar/README.md new file mode 100644 index 0000000..ee5689c --- /dev/null +++ b/src/storage/artifacts/stellar/README.md @@ -0,0 +1,374 @@ +# Soroban Analysis Artifact Storage + +Storage system for persisting and retrieving generated analysis artifacts in GasGuard. + +## Overview + +The Soroban Analysis Artifact Storage system provides comprehensive functionality for: + +- **Storing analysis artifacts** - Save reports, snapshots, and analysis results with metadata +- **Metadata management** - Track artifact properties, tags, and relationships +- **Efficient retrieval** - Query and retrieve artifacts with flexible search capabilities +- **Caching** - Configurable in-memory caching for frequently accessed artifacts +- **Integrity verification** - Checksum validation for data consistency +- **Batch operations** - Handle multiple artifacts efficiently +- **Statistics tracking** - Monitor storage usage and artifact distribution + +## Components + +### 1. ArtifactStorageService + +Core service for storing and managing artifacts on disk. + +```typescript +import { ArtifactStorageService, Artifact } from './artifact-storage.service'; + +const storage = new ArtifactStorageService({ + baseDir: './artifacts', + maxSizeBytes: 100 * 1024 * 1024, // 100MB max + retentionMs: 90 * 24 * 60 * 60 * 1000, // 90 days +}); + +// Store artifact +const artifact: Artifact = { + metadata: { + artifactId: 'report-001', + contractId: 'CABC123', + artifactType: 'report', + format: 'json', + network: 'testnet', + networkPassphrase: 'Test SDF Network', + ledgerSequence: 12345, + createdAt: Date.now(), + updatedAt: Date.now(), + generatedBy: 'gas-analyzer', + toolVersion: '1.0.0', + sizeBytes: 0, + tags: ['audit', 'security'], + }, + content: { /* report data */ }, +}; + +await storage.storeArtifact(artifact); +``` + +### 2. ArtifactRetriever + +High-level interface for retrieving artifacts with caching support. + +```typescript +import { ArtifactRetriever } from './artifact-retriever'; + +const retriever = new ArtifactRetriever(storage, { + enableCache: true, + cacheTtlMs: 5 * 60 * 1000, // 5 minutes + maxCacheSize: 100, +}); + +// Get artifact +const artifact = await retriever.getArtifact('CABC123', 'report-001'); + +// Get latest artifact of type +const latest = await retriever.getLatestArtifact('CABC123', 'report'); + +// Get artifacts by type +const reports = await retriever.getArtifactsByType('CABC123', 'report', 10); + +// Get artifacts by tags +const tagged = await retriever.getArtifactsByTags('CABC123', ['security']); + +// Search with complex query +const results = await retriever.searchArtifacts({ + contractId: 'CABC123', + artifactType: 'report', + network: 'mainnet', + createdAfter: Date.now() - 7 * 24 * 60 * 60 * 1000, +}); +``` + +### 3. MetadataManager + +Manages artifact metadata indexing and searching. + +```typescript +import { MetadataManager } from './metadata-manager'; + +const manager = new MetadataManager('./artifacts'); + +// Find by criteria +const artifacts = manager.findByCriteria({ + contractId: 'CABC123', + artifactType: 'report', + network: 'testnet', +}); + +// Get by type +const reports = manager.getMetadataByType('report'); + +// Get by tags +const tagged = manager.getMetadataByTags(['security']); + +// Get statistics +const stats = manager.getStatistics(); +``` + +## Usage Examples + +### Basic Usage + +```typescript +import { initializeArtifactStorage } from './index'; + +const { storageService, retriever, metadataManager } = + initializeArtifactStorage('./artifacts'); + +// Store an artifact +const artifact = { + metadata: { + artifactId: 'analysis-001', + contractId: 'CABC123', + artifactType: 'analysis', + format: 'json', + network: 'testnet', + networkPassphrase: 'Test SDF Network', + ledgerSequence: 12345, + createdAt: Date.now(), + updatedAt: Date.now(), + generatedBy: 'analyzer', + toolVersion: '1.0.0', + sizeBytes: 0, + description: 'Gas optimization analysis', + tags: ['gas', 'optimization'], + }, + content: { + totalGasUsed: 50000, + optimizationPotential: '15%', + recommendations: ['Remove unused variables', 'Optimize loops'], + }, +}; + +const result = await storageService.storeArtifact(artifact); +if (result.success) { + console.log(`Stored artifact at: ${result.filePath}`); +} +``` + +### Querying Artifacts + +```typescript +// Get all artifacts for a contract +const contractArtifacts = await retriever.getContractArtifacts('CABC123'); + +// Get latest report +const latestReport = await retriever.getLatestArtifact('CABC123', 'report'); + +// Get artifacts in date range +const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; +const recent = await retriever.getArtifactsInDateRange('CABC123', weekAgo, Date.now()); + +// Search with advanced query +const query = { + contractId: 'CABC123', + artifactType: 'report', + network: 'mainnet', + tags: ['security'], + createdAfter: weekAgo, + sortBy: 'createdAt', + sortOrder: 'desc', + limit: 10, +}; + +const results = await storageService.queryArtifacts(query); +``` + +### Batch Operations + +```typescript +// Store multiple artifacts at once +const artifacts = [ + { metadata: { /* ... */ }, content: { /* ... */ } }, + { metadata: { /* ... */ }, content: { /* ... */ } }, + { metadata: { /* ... */ }, content: { /* ... */ } }, +]; + +const batchResult = await storageService.storeArtifactBatch(artifacts); +console.log(`Stored: ${batchResult.succeeded}, Failed: ${batchResult.failed}`); +``` + +### Cleanup and Maintenance + +```typescript +// Clean up expired artifacts +const cleanupResult = await storageService.cleanupExpiredArtifacts(); +console.log(`Cleaned: ${cleanupResult.succeeded} artifacts`); + +// Get storage statistics +const stats = storageService.getStorageStatistics(); +console.log(`Total artifacts: ${stats.totalArtifacts}`); +console.log(`Total size: ${(stats.totalSizeBytes / 1024 / 1024).toFixed(2)}MB`); +console.log(`By type: ${JSON.stringify(stats.byType)}`); + +// Rebuild metadata index +metadataManager.rebuildIndex(); + +// Clean orphaned metadata +const removed = metadataManager.cleanupOrphanedMetadata(); +console.log(`Removed ${removed} orphaned metadata files`); +``` + +## Types + +### ArtifactMetadata + +```typescript +interface ArtifactMetadata { + artifactId: string; + contractId: string; + artifactType: 'report' | 'snapshot' | 'analysis' | 'metrics' | + 'recommendations' | 'security_audit' | 'performance_profile'; + format: 'json' | 'markdown' | 'html' | 'csv' | 'binary'; + network: 'testnet' | 'mainnet' | 'standalone'; + networkPassphrase: string; + ledgerSequence: number; + createdAt: number; + updatedAt: number; + description?: string; + tags?: string[]; + generatedBy: string; + toolVersion: string; + sizeBytes: number; + checksum?: string; + expiresAt?: number; + relatedArtifacts?: string[]; +} +``` + +### ArtifactQuery + +```typescript +interface ArtifactQuery { + artifactId?: string; + contractId?: string; + artifactType?: ArtifactMetadata["artifactType"]; + network?: ArtifactMetadata["network"]; + tags?: string[]; + createdAfter?: number; + createdBefore?: number; + generatedBy?: string; + limit?: number; + offset?: number; + sortBy?: 'createdAt' | 'updatedAt' | 'sizeBytes'; + sortOrder?: 'asc' | 'desc'; +} +``` + +## Configuration + +### ArtifactStorageConfig + +```typescript +interface ArtifactStorageConfig { + baseDir: string; // Storage directory + enableCompression: boolean; // Compress large artifacts + maxSizeBytes: number; // Maximum artifact size + retentionMs: number; // Auto-deletion after time + verifyChecksum: boolean; // Verify data integrity + createBackups: boolean; // Create backup copies + backupDir?: string; // Backup directory +} +``` + +### ArtifactRetrievalConfig + +```typescript +interface ArtifactRetrievalConfig { + enableCache?: boolean; // In-memory caching + cacheTtlMs?: number; // Cache time-to-live + maxCacheSize?: number; // Max cached items +} +``` + +## Directory Structure + +``` +artifacts/ +├── CONTRACT_ID/ +│ ├── 2024-01-15/ +│ │ ├── artifact-001/ +│ │ │ ├── artifact +│ │ │ └── metadata.json +│ │ └── artifact-002/ +│ │ ├── artifact +│ │ └── metadata.json +│ └── 2024-01-14/ +│ └── artifact-003/ +│ ├── artifact +│ └── metadata.json +├── backups/ +│ └── CONTRACT_ID/ +│ └── artifact-001-1705326000000/ +│ ├── artifact +│ └── metadata.json +├── .metadata.json # Central metadata index +└── .index.json # Quick lookup index +``` + +## Best Practices + +1. **Tag artifacts appropriately** - Use consistent tags for easy searching and categorization +2. **Monitor storage size** - Regularly check statistics and clean up old artifacts +3. **Use meaningful descriptions** - Include context about the artifact in the description field +4. **Verify checksums** - Enable checksum verification for critical artifacts +5. **Set expiration times** - Use `expiresAt` for temporary artifacts +6. **Batch operations** - Use batch methods for multiple artifacts +7. **Cache configuration** - Adjust cache settings based on access patterns +8. **Backup important artifacts** - Enable backups for critical data + +## Error Handling + +```typescript +try { + const result = await storageService.storeArtifact(artifact); + if (!result.success) { + console.error(`Storage failed: ${result.error}`); + } +} catch (error) { + console.error(`Storage error: ${error}`); +} + +try { + const artifact = await retriever.getArtifact(contractId, artifactId); + if (!artifact) { + console.warn('Artifact not found'); + } +} catch (error) { + console.error(`Retrieval error: ${error}`); +} +``` + +## Performance Considerations + +- Enable caching for frequently accessed artifacts +- Use queries with filters to reduce returned data +- Batch store operations when handling multiple artifacts +- Set appropriate retention times to manage disk usage +- Consider compression for large artifacts +- Monitor cache statistics and adjust max cache size as needed + +## Testing + +Run the test suite: + +```bash +npm test -- src/storage/artifacts/stellar/__tests__/artifact-storage.spec.ts +``` + +Tests cover: +- Storing and retrieving artifacts +- Duplicate detection and overwriting +- Querying with various filters +- Batch operations +- Statistics tracking +- Deletion and cleanup +- Caching behavior +- Metadata management diff --git a/src/storage/artifacts/stellar/__tests__/artifact-storage.spec.ts b/src/storage/artifacts/stellar/__tests__/artifact-storage.spec.ts new file mode 100644 index 0000000..5e0535e --- /dev/null +++ b/src/storage/artifacts/stellar/__tests__/artifact-storage.spec.ts @@ -0,0 +1,582 @@ +/** + * Soroban Analysis Artifact Storage Tests + * + * Tests for artifact storage, retrieval, and metadata management. + */ + +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import ArtifactStorageService from "./artifact-storage.service"; +import ArtifactRetriever from "./artifact-retriever"; +import MetadataManager from "./metadata-manager"; +import { + Artifact, + ArtifactMetadata, + ArtifactQuery, + StorageResult, +} from "./types"; + +describe("ArtifactStorageService", () => { + let tempDir: string; + let storageService: ArtifactStorageService; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "artifact-storage-")); + storageService = new ArtifactStorageService({ + baseDir: tempDir, + enableCompression: false, + maxSizeBytes: 1024 * 1024, + retentionMs: 0, + verifyChecksum: true, + createBackups: true, + backupDir: path.join(tempDir, "backups"), + }); + }); + + afterEach(() => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (error) { + // Ignore cleanup errors + } + }); + + describe("store and retrieve artifacts", () => { + it("should store artifact successfully", async () => { + const metadata: ArtifactMetadata = { + artifactId: "test-artifact-1", + contractId: "CABC123", + artifactType: "report", + format: "json", + network: "testnet", + networkPassphrase: "Test SDF Network", + ledgerSequence: 12345, + createdAt: Date.now(), + updatedAt: Date.now(), + generatedBy: "test-analyzer", + toolVersion: "1.0.0", + sizeBytes: 0, + }; + + const artifact: Artifact = { + metadata, + content: { summary: "Test artifact content" }, + }; + + const result = await storageService.storeArtifact(artifact); + + expect(result.success).toBe(true); + expect(result.artifactId).toBe("test-artifact-1"); + expect(result.filePath).toBeTruthy(); + }); + + it("should retrieve stored artifact", async () => { + const metadata: ArtifactMetadata = { + artifactId: "test-artifact-2", + contractId: "CABC123", + artifactType: "snapshot", + format: "json", + network: "testnet", + networkPassphrase: "Test SDF Network", + ledgerSequence: 12345, + createdAt: Date.now(), + updatedAt: Date.now(), + generatedBy: "test-analyzer", + toolVersion: "1.0.0", + sizeBytes: 0, + }; + + const originalContent = { data: "test snapshot data" }; + const artifact: Artifact = { + metadata, + content: originalContent, + }; + + await storageService.storeArtifact(artifact); + + const retrieved = await storageService.retrieveArtifact( + "CABC123", + "test-artifact-2", + ); + + expect(retrieved).not.toBeNull(); + expect(retrieved!.metadata.artifactId).toBe("test-artifact-2"); + expect(retrieved!.content).toEqual(originalContent); + }); + + it("should prevent duplicate artifact without overwrite flag", async () => { + const metadata: ArtifactMetadata = { + artifactId: "test-artifact-3", + contractId: "CABC123", + artifactType: "analysis", + format: "json", + network: "testnet", + networkPassphrase: "Test SDF Network", + ledgerSequence: 12345, + createdAt: Date.now(), + updatedAt: Date.now(), + generatedBy: "test-analyzer", + toolVersion: "1.0.0", + sizeBytes: 0, + }; + + const artifact: Artifact = { + metadata, + content: { data: "original" }, + }; + + await storageService.storeArtifact(artifact); + const secondResult = await storageService.storeArtifact(artifact); + + expect(secondResult.success).toBe(false); + expect(secondResult.error).toContain("already exists"); + }); + + it("should overwrite artifact with overwrite flag", async () => { + const metadata: ArtifactMetadata = { + artifactId: "test-artifact-4", + contractId: "CABC123", + artifactType: "report", + format: "json", + network: "testnet", + networkPassphrase: "Test SDF Network", + ledgerSequence: 12345, + createdAt: Date.now(), + updatedAt: Date.now(), + generatedBy: "test-analyzer", + toolVersion: "1.0.0", + sizeBytes: 0, + }; + + const artifact1: Artifact = { + metadata, + content: { data: "version 1" }, + }; + + await storageService.storeArtifact(artifact1); + + const artifact2: Artifact = { + ...artifact1, + content: { data: "version 2" }, + }; + + const result = await storageService.storeArtifact(artifact2, { + overwrite: true, + }); + + expect(result.success).toBe(true); + + const retrieved = await storageService.retrieveArtifact( + "CABC123", + "test-artifact-4", + ); + expect(retrieved!.content).toEqual({ data: "version 2" }); + }); + }); + + describe("query and search", () => { + beforeEach(async () => { + const artifacts: Artifact[] = [ + { + metadata: { + artifactId: "artifact-1", + contractId: "CONTRACT-A", + artifactType: "report", + format: "json", + network: "testnet", + networkPassphrase: "Test SDF Network", + ledgerSequence: 1000, + createdAt: Date.now() - 10000, + updatedAt: Date.now() - 10000, + generatedBy: "analyzer-1", + toolVersion: "1.0.0", + sizeBytes: 0, + tags: ["audit", "security"], + }, + content: { type: "report" }, + }, + { + metadata: { + artifactId: "artifact-2", + contractId: "CONTRACT-A", + artifactType: "snapshot", + format: "json", + network: "mainnet", + networkPassphrase: "Public Global Stellar Network", + ledgerSequence: 2000, + createdAt: Date.now() - 5000, + updatedAt: Date.now() - 5000, + generatedBy: "analyzer-1", + toolVersion: "1.0.0", + sizeBytes: 0, + tags: ["state"], + }, + content: { type: "snapshot" }, + }, + { + metadata: { + artifactId: "artifact-3", + contractId: "CONTRACT-B", + artifactType: "metrics", + format: "json", + network: "testnet", + networkPassphrase: "Test SDF Network", + ledgerSequence: 3000, + createdAt: Date.now(), + updatedAt: Date.now(), + generatedBy: "analyzer-2", + toolVersion: "1.1.0", + sizeBytes: 0, + }, + content: { type: "metrics" }, + }, + ]; + + for (const artifact of artifacts) { + await storageService.storeArtifact(artifact); + } + }); + + it("should query artifacts by contract ID", async () => { + const query: ArtifactQuery = { contractId: "CONTRACT-A" }; + const results = await storageService.queryArtifacts(query); + + expect(results).toHaveLength(2); + expect(results.every((r) => r.contractId === "CONTRACT-A")).toBe( + true, + ); + }); + + it("should query artifacts by type", async () => { + const query: ArtifactQuery = { artifactType: "report" }; + const results = await storageService.queryArtifacts(query); + + expect(results).toHaveLength(1); + expect(results[0].artifactType).toBe("report"); + }); + + it("should query artifacts by network", async () => { + const query: ArtifactQuery = { network: "mainnet" }; + const results = await storageService.queryArtifacts(query); + + expect(results).toHaveLength(1); + expect(results[0].network).toBe("mainnet"); + }); + + it("should query with pagination", async () => { + const query: ArtifactQuery = { limit: 2, offset: 0 }; + const results = await storageService.queryArtifacts(query); + + expect(results.length).toBeLessThanOrEqual(2); + }); + + it("should sort query results", async () => { + const query: ArtifactQuery = { + sortBy: "createdAt", + sortOrder: "desc", + }; + const results = await storageService.queryArtifacts(query); + + for (let i = 0; i < results.length - 1; i++) { + expect(results[i].createdAt).toBeGreaterThanOrEqual( + results[i + 1].createdAt, + ); + } + }); + }); + + describe("batch operations", () => { + it("should store multiple artifacts", async () => { + const artifacts: Artifact[] = [ + { + metadata: { + artifactId: "batch-1", + contractId: "CONTRACT-X", + artifactType: "report", + format: "json", + network: "testnet", + networkPassphrase: "Test SDF Network", + ledgerSequence: 1000, + createdAt: Date.now(), + updatedAt: Date.now(), + generatedBy: "batch-test", + toolVersion: "1.0.0", + sizeBytes: 0, + }, + content: { item: 1 }, + }, + { + metadata: { + artifactId: "batch-2", + contractId: "CONTRACT-X", + artifactType: "analysis", + format: "json", + network: "testnet", + networkPassphrase: "Test SDF Network", + ledgerSequence: 1000, + createdAt: Date.now(), + updatedAt: Date.now(), + generatedBy: "batch-test", + toolVersion: "1.0.0", + sizeBytes: 0, + }, + content: { item: 2 }, + }, + ]; + + const result = await storageService.storeArtifactBatch(artifacts); + + expect(result.total).toBe(2); + expect(result.succeeded).toBe(2); + expect(result.failed).toBe(0); + }); + }); + + describe("statistics", () => { + beforeEach(async () => { + const artifacts: Artifact[] = [ + { + metadata: { + artifactId: "stat-1", + contractId: "CONTRACT-STAT", + artifactType: "report", + format: "json", + network: "testnet", + networkPassphrase: "Test SDF Network", + ledgerSequence: 1000, + createdAt: Date.now(), + updatedAt: Date.now(), + generatedBy: "stats-test", + toolVersion: "1.0.0", + sizeBytes: 0, + }, + content: { test: "data" }, + }, + { + metadata: { + artifactId: "stat-2", + contractId: "CONTRACT-STAT", + artifactType: "snapshot", + format: "json", + network: "mainnet", + networkPassphrase: "Public Global Stellar Network", + ledgerSequence: 2000, + createdAt: Date.now(), + updatedAt: Date.now(), + generatedBy: "stats-test", + toolVersion: "1.0.0", + sizeBytes: 0, + }, + content: { test: "data" }, + }, + ]; + + for (const artifact of artifacts) { + await storageService.storeArtifact(artifact); + } + }); + + it("should provide storage statistics", async () => { + const stats = storageService.getStorageStatistics(); + + expect(stats.totalArtifacts).toBeGreaterThan(0); + expect(stats.totalSizeBytes).toBeGreaterThan(0); + expect(stats.averageSizeBytes).toBeGreaterThan(0); + }); + + it("should track artifacts by type", async () => { + const stats = storageService.getStorageStatistics(); + + expect(stats.byType.report).toBeGreaterThan(0); + expect(stats.byType.snapshot).toBeGreaterThan(0); + }); + }); + + describe("deletion", () => { + it("should delete artifact", async () => { + const metadata: ArtifactMetadata = { + artifactId: "delete-test", + contractId: "CONTRACT-DEL", + artifactType: "report", + format: "json", + network: "testnet", + networkPassphrase: "Test SDF Network", + ledgerSequence: 1000, + createdAt: Date.now(), + updatedAt: Date.now(), + generatedBy: "delete-test", + toolVersion: "1.0.0", + sizeBytes: 0, + }; + + const artifact: Artifact = { + metadata, + content: { test: "data" }, + }; + + await storageService.storeArtifact(artifact); + + const result = await storageService.deleteArtifact( + "CONTRACT-DEL", + "delete-test", + ); + + expect(result.success).toBe(true); + + const retrieved = await storageService.retrieveArtifact( + "CONTRACT-DEL", + "delete-test", + ); + + expect(retrieved).toBeNull(); + }); + }); +}); + +describe("ArtifactRetriever", () => { + let tempDir: string; + let storageService: ArtifactStorageService; + let retriever: ArtifactRetriever; + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "artifact-retriever-")); + storageService = new ArtifactStorageService({ baseDir: tempDir }); + retriever = new ArtifactRetriever(storageService, { + enableCache: true, + cacheTtlMs: 60000, + maxCacheSize: 10, + }); + + // Store test artifact + const artifact: Artifact = { + metadata: { + artifactId: "retriever-test", + contractId: "CONTRACT-RETR", + artifactType: "report", + format: "json", + network: "testnet", + networkPassphrase: "Test SDF Network", + ledgerSequence: 1000, + createdAt: Date.now(), + updatedAt: Date.now(), + generatedBy: "retriever-test", + toolVersion: "1.0.0", + sizeBytes: 0, + }, + content: { test: "content" }, + }; + + await storageService.storeArtifact(artifact); + }); + + afterEach(() => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (error) { + // Ignore cleanup errors + } + }); + + it("should retrieve artifact", async () => { + const artifact = await retriever.getArtifact( + "CONTRACT-RETR", + "retriever-test", + ); + + expect(artifact).not.toBeNull(); + expect(artifact!.metadata.artifactId).toBe("retriever-test"); + }); + + it("should cache retrieved artifacts", async () => { + await retriever.getArtifact("CONTRACT-RETR", "retriever-test"); + + const stats = retriever.getCacheStats(); + expect(stats.size).toBe(1); + }); + + it("should check artifact existence", async () => { + const exists = await retriever.artifactExists( + "CONTRACT-RETR", + "retriever-test", + ); + + expect(exists).toBe(true); + }); + + it("should return null for non-existent artifact", async () => { + const artifact = await retriever.getArtifact( + "CONTRACT-RETR", + "non-existent", + ); + + expect(artifact).toBeNull(); + }); +}); + +describe("MetadataManager", () => { + let tempDir: string; + let metadataManager: MetadataManager; + + beforeEach(() => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "metadata-manager-"), + ); + metadataManager = new MetadataManager(tempDir); + }); + + afterEach(() => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (error) { + // Ignore cleanup errors + } + }); + + it("should register artifact metadata", () => { + const metadata: ArtifactMetadata = { + artifactId: "metadata-test", + contractId: "CONTRACT-META", + artifactType: "report", + format: "json", + network: "testnet", + networkPassphrase: "Test SDF Network", + ledgerSequence: 1000, + createdAt: Date.now(), + updatedAt: Date.now(), + generatedBy: "metadata-test", + toolVersion: "1.0.0", + sizeBytes: 100, + }; + + metadataManager.registerArtifact(metadata); + + const retrieved = metadataManager.getContractMetadata( + "CONTRACT-META", + ); + expect(retrieved).toHaveLength(1); + }); + + it("should get metadata by type", () => { + const metadata: ArtifactMetadata = { + artifactId: "type-test", + contractId: "CONTRACT-TYPE", + artifactType: "analysis", + format: "json", + network: "testnet", + networkPassphrase: "Test SDF Network", + ledgerSequence: 1000, + createdAt: Date.now(), + updatedAt: Date.now(), + generatedBy: "type-test", + toolVersion: "1.0.0", + sizeBytes: 100, + }; + + metadataManager.registerArtifact(metadata); + + const results = metadataManager.getMetadataByType("analysis"); + expect(results).toHaveLength(1); + expect(results[0].artifactType).toBe("analysis"); + }); +}); diff --git a/src/storage/artifacts/stellar/artifact-retriever.ts b/src/storage/artifacts/stellar/artifact-retriever.ts new file mode 100644 index 0000000..1e959a2 --- /dev/null +++ b/src/storage/artifacts/stellar/artifact-retriever.ts @@ -0,0 +1,347 @@ +/** + * Soroban Analysis Artifact Retrieval Utilities + * + * Helper utilities for retrieving, searching, and accessing stored artifacts + * with convenient query interfaces and caching support. + */ + +import { + Artifact, + ArtifactMetadata, + ArtifactQuery, + RetrieveArtifactOptions, +} from "./types"; +import ArtifactStorageService from "./artifact-storage.service"; + +/** + * Artifact retrieval cache entry + */ +interface CacheEntry { + artifact: Artifact; + timestamp: number; +} + +/** + * Configuration for artifact retrieval + */ +export interface ArtifactRetrievalConfig { + /** Enable caching of retrieved artifacts */ + enableCache?: boolean; + /** Cache TTL in milliseconds */ + cacheTtlMs?: number; + /** Maximum number of items in cache */ + maxCacheSize?: number; +} + +/** + * High-level artifact retrieval interface + */ +export class ArtifactRetriever { + private storageService: ArtifactStorageService; + private cache: Map = new Map(); + private config: Required; + + constructor( + storageService: ArtifactStorageService, + config: ArtifactRetrievalConfig = {}, + ) { + this.storageService = storageService; + this.config = { + enableCache: config.enableCache ?? true, + cacheTtlMs: config.cacheTtlMs ?? 5 * 60 * 1000, // 5 minutes default + maxCacheSize: config.maxCacheSize ?? 100, + }; + } + + /** + * Retrieve artifact by ID + */ + async getArtifact( + contractId: string, + artifactId: string, + options: RetrieveArtifactOptions = {}, + ): Promise { + const cacheKey = `${contractId}:${artifactId}`; + + // Check cache + if (this.config.enableCache) { + const cached = this.getFromCache(cacheKey); + if (cached) { + return cached; + } + } + + // Retrieve from storage + const artifact = await this.storageService.retrieveArtifact( + contractId, + artifactId, + options, + ); + + // Cache result + if (artifact && this.config.enableCache) { + this.addToCache(cacheKey, artifact); + } + + return artifact; + } + + /** + * Get latest artifact of a specific type for a contract + */ + async getLatestArtifact( + contractId: string, + artifactType: ArtifactMetadata["artifactType"], + options: RetrieveArtifactOptions = {}, + ): Promise { + const query: ArtifactQuery = { + contractId, + artifactType, + limit: 1, + sortBy: "createdAt", + sortOrder: "desc", + }; + + const results = await this.storageService.queryArtifacts(query); + + if (results.length === 0) { + return null; + } + + return this.getArtifact( + contractId, + results[0].artifactId, + options, + ); + } + + /** + * Get multiple artifacts by type + */ + async getArtifactsByType( + contractId: string, + artifactType: ArtifactMetadata["artifactType"], + limit: number = 10, + ): Promise { + const query: ArtifactQuery = { + contractId, + artifactType, + limit, + sortBy: "createdAt", + sortOrder: "desc", + }; + + const metadataList = await this.storageService.queryArtifacts(query); + const artifacts: Artifact[] = []; + + for (const metadata of metadataList) { + const artifact = await this.getArtifact( + contractId, + metadata.artifactId, + { metadataOnly: false }, + ); + if (artifact) { + artifacts.push(artifact); + } + } + + return artifacts; + } + + /** + * Get artifacts by tags + */ + async getArtifactsByTags( + contractId: string, + tags: string[], + limit: number = 10, + ): Promise { + const query: ArtifactQuery = { + contractId, + tags, + limit, + sortBy: "createdAt", + sortOrder: "desc", + }; + + const metadataList = await this.storageService.queryArtifacts(query); + const artifacts: Artifact[] = []; + + for (const metadata of metadataList) { + const artifact = await this.getArtifact( + contractId, + metadata.artifactId, + { metadataOnly: false }, + ); + if (artifact) { + artifacts.push(artifact); + } + } + + return artifacts; + } + + /** + * Get only metadata without content + */ + async getArtifactMetadata( + contractId: string, + artifactId: string, + ): Promise { + const artifact = await this.getArtifact( + contractId, + artifactId, + { metadataOnly: true }, + ); + return artifact ? artifact.metadata : null; + } + + /** + * Get artifact content only + */ + async getArtifactContent( + contractId: string, + artifactId: string, + ): Promise | null> { + const artifact = await this.getArtifact(contractId, artifactId); + return artifact ? artifact.content : null; + } + + /** + * Search artifacts with complex query + */ + async searchArtifacts( + query: ArtifactQuery, + ): Promise { + return this.storageService.queryArtifacts(query); + } + + /** + * Get all artifacts for a contract + */ + async getContractArtifacts( + contractId: string, + limit: number = 50, + ): Promise { + const query: ArtifactQuery = { + contractId, + limit, + sortBy: "createdAt", + sortOrder: "desc", + }; + + return this.storageService.queryArtifacts(query); + } + + /** + * Check if artifact exists + */ + async artifactExists( + contractId: string, + artifactId: string, + ): Promise { + const artifact = await this.getArtifact( + contractId, + artifactId, + { metadataOnly: true }, + ); + return artifact !== null; + } + + /** + * Get artifact creation and modification times + */ + async getArtifactTimestamps( + contractId: string, + artifactId: string, + ): Promise<{ createdAt: number; updatedAt: number } | null> { + const metadata = await this.getArtifactMetadata( + contractId, + artifactId, + ); + return metadata + ? { createdAt: metadata.createdAt, updatedAt: metadata.updatedAt } + : null; + } + + /** + * Get artifacts created within a date range + */ + async getArtifactsInDateRange( + contractId: string, + startTime: number, + endTime: number, + ): Promise { + const query: ArtifactQuery = { + contractId, + createdAfter: startTime, + createdBefore: endTime, + sortBy: "createdAt", + sortOrder: "desc", + }; + + return this.storageService.queryArtifacts(query); + } + + /** + * Clear cache + */ + clearCache(): void { + this.cache.clear(); + } + + /** + * Get cache statistics + */ + getCacheStats(): { + size: number; + maxSize: number; + utilized: number; + } { + return { + size: this.cache.size, + maxSize: this.config.maxCacheSize, + utilized: (this.cache.size / this.config.maxCacheSize) * 100, + }; + } + + /** + * Get from cache with TTL check + */ + private getFromCache(key: string): Artifact | null { + const entry = this.cache.get(key); + + if (!entry) { + return null; + } + + // Check if entry has expired + if (Date.now() - entry.timestamp > this.config.cacheTtlMs) { + this.cache.delete(key); + return null; + } + + return entry.artifact; + } + + /** + * Add to cache with size management + */ + private addToCache(key: string, artifact: Artifact): void { + // Enforce max cache size + if (this.cache.size >= this.config.maxCacheSize) { + // Remove oldest entry + const firstKey = this.cache.keys().next().value; + if (firstKey) { + this.cache.delete(firstKey); + } + } + + this.cache.set(key, { + artifact, + timestamp: Date.now(), + }); + } +} + +export default ArtifactRetriever; diff --git a/src/storage/artifacts/stellar/artifact-storage.service.ts b/src/storage/artifacts/stellar/artifact-storage.service.ts new file mode 100644 index 0000000..c713ec9 --- /dev/null +++ b/src/storage/artifacts/stellar/artifact-storage.service.ts @@ -0,0 +1,548 @@ +/** + * Soroban Analysis Artifact Storage Service + * + * Handles persistence of generated analysis artifacts including reports, + * snapshots, and metadata. Supports storing, retrieving, and managing artifacts. + */ + +import * as fs from "fs"; +import * as path from "path"; +import { createHash } from "crypto"; +import { + Artifact, + ArtifactMetadata, + ArtifactQuery, + StorageResult, + BatchStorageResult, + ArtifactStorageConfig, + StorageStatistics, + StoreArtifactOptions, + RetrieveArtifactOptions, +} from "./types"; + +/** + * Service for storing and retrieving Soroban analysis artifacts + */ +export class ArtifactStorageService { + private config: ArtifactStorageConfig; + private metadataFile: string; + + constructor(config: Partial = {}) { + this.config = { + baseDir: config.baseDir || "./artifacts", + enableCompression: config.enableCompression ?? false, + maxSizeBytes: config.maxSizeBytes ?? 0, + retentionMs: config.retentionMs ?? 0, + verifyChecksum: config.verifyChecksum ?? true, + createBackups: config.createBackups ?? true, + backupDir: config.backupDir || "./artifacts/backups", + }; + + this.metadataFile = path.join(this.config.baseDir, ".metadata.json"); + + // Initialize storage directory + this.initializeStorage(); + } + + /** + * Initialize storage directories + */ + private initializeStorage(): void { + try { + if (!fs.existsSync(this.config.baseDir)) { + fs.mkdirSync(this.config.baseDir, { recursive: true }); + } + if ( + this.config.createBackups && + !fs.existsSync(this.config.backupDir!) + ) { + fs.mkdirSync(this.config.backupDir!, { recursive: true }); + } + } catch (error) { + throw new Error(`Failed to initialize storage: ${error}`); + } + } + + /** + * Store an artifact with metadata + */ + async storeArtifact( + artifact: Artifact, + options: StoreArtifactOptions = {}, + ): Promise { + try { + const { artifactId, contractId } = artifact.metadata; + + // Check if artifact already exists + const artifactDir = path.join( + this.config.baseDir, + this.getArtifactPath(contractId, artifactId), + ); + const artifactPath = path.join(artifactDir, "artifact"); + + if (fs.existsSync(artifactPath) && !options.overwrite) { + return { + success: false, + error: `Artifact ${artifactId} already exists. Use overwrite: true to replace.`, + }; + } + + // Handle backup of existing artifact + if (fs.existsSync(artifactPath) && options.backup && this.config.createBackups) { + this.backupArtifact(contractId, artifactId); + } + + // Create artifact directory + fs.mkdirSync(artifactDir, { recursive: true }); + + // Serialize content + const contentString = + typeof artifact.content === "string" + ? artifact.content + : JSON.stringify(artifact.content, null, 2); + + // Check size constraint + const contentSize = Buffer.byteLength(contentString, "utf8"); + if ( + this.config.maxSizeBytes > 0 && + contentSize > this.config.maxSizeBytes + ) { + return { + success: false, + error: `Artifact exceeds maximum size of ${this.config.maxSizeBytes} bytes (${contentSize} bytes)`, + }; + } + + // Calculate checksum + const checksum = createHash("sha256").update(contentString).digest("hex"); + + // Update metadata + artifact.metadata.sizeBytes = contentSize; + artifact.metadata.checksum = checksum; + artifact.metadata.updatedAt = Date.now(); + + // Store artifact content + fs.writeFileSync(artifactPath, contentString, "utf8"); + + // Store metadata + const metadataPath = path.join(artifactDir, "metadata.json"); + fs.writeFileSync( + metadataPath, + JSON.stringify(artifact.metadata, null, 2), + "utf8", + ); + + // Update central metadata index + this.updateMetadataIndex(artifact.metadata); + + return { + success: true, + artifactId, + filePath: artifactPath, + details: { + sizeBytes: contentSize, + checksum, + backupCreated: fs.existsSync(artifactPath) && options.backup, + }, + }; + } catch (error) { + return { + success: false, + error: `Failed to store artifact: ${error}`, + }; + } + } + + /** + * Retrieve an artifact + */ + async retrieveArtifact( + contractId: string, + artifactId: string, + options: RetrieveArtifactOptions = {}, + ): Promise { + try { + const artifactDir = path.join( + this.config.baseDir, + this.getArtifactPath(contractId, artifactId), + ); + const artifactPath = path.join(artifactDir, "artifact"); + const metadataPath = path.join(artifactDir, "metadata.json"); + + // Check if artifact exists + if (!fs.existsSync(artifactPath) || !fs.existsSync(metadataPath)) { + return null; + } + + // Load metadata + const metadataContent = fs.readFileSync(metadataPath, "utf8"); + const metadata: ArtifactMetadata = JSON.parse(metadataContent); + + // If metadata only requested, return early + if (options.metadataOnly) { + return { + metadata, + content: "", + }; + } + + // Load artifact content + let content = fs.readFileSync(artifactPath, "utf8"); + + // Verify checksum if configured + if (options.verifyChecksum && metadata.checksum) { + const actualChecksum = createHash("sha256") + .update(content) + .digest("hex"); + if (actualChecksum !== metadata.checksum) { + throw new Error("Artifact checksum verification failed"); + } + } + + // Try to parse JSON content if applicable + let parsedContent: string | Record = content; + if (metadata.format === "json") { + try { + parsedContent = JSON.parse(content); + } catch { + // Keep as string if JSON parsing fails + } + } + + return { + metadata, + content: parsedContent, + }; + } catch (error) { + throw new Error(`Failed to retrieve artifact: ${error}`); + } + } + + /** + * Query artifacts by metadata criteria + */ + async queryArtifacts(query: ArtifactQuery): Promise { + try { + const allMetadata = this.loadAllMetadata(); + + // Apply filters + let results = allMetadata.filter((m) => { + if (query.artifactId && m.artifactId !== query.artifactId) return false; + if (query.contractId && m.contractId !== query.contractId) return false; + if (query.artifactType && m.artifactType !== query.artifactType) + return false; + if (query.network && m.network !== query.network) return false; + if (query.generatedBy && m.generatedBy !== query.generatedBy) + return false; + + if (query.createdAfter && m.createdAt < query.createdAfter) + return false; + if (query.createdBefore && m.createdAt > query.createdBefore) + return false; + + if (query.tags && query.tags.length > 0) { + const hasMatchingTag = + m.tags && + m.tags.some((tag) => query.tags!.includes(tag)); + if (!hasMatchingTag) return false; + } + + return true; + }); + + // Sort results + if (query.sortBy) { + const multiplier = query.sortOrder === "desc" ? -1 : 1; + results.sort((a, b) => { + const aValue = a[query.sortBy as keyof ArtifactMetadata]; + const bValue = b[query.sortBy as keyof ArtifactMetadata]; + + if (typeof aValue === "number" && typeof bValue === "number") { + return (aValue - bValue) * multiplier; + } + return 0; + }); + } + + // Apply pagination + if (query.limit) { + const offset = query.offset || 0; + results = results.slice(offset, offset + query.limit); + } + + return results; + } catch (error) { + throw new Error(`Failed to query artifacts: ${error}`); + } + } + + /** + * Store multiple artifacts + */ + async storeArtifactBatch( + artifacts: Artifact[], + options: StoreArtifactOptions = {}, + ): Promise { + const results: StorageResult[] = []; + + for (const artifact of artifacts) { + const result = await this.storeArtifact(artifact, options); + results.push(result); + } + + const succeeded = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + + return { + total: artifacts.length, + succeeded, + failed, + results, + }; + } + + /** + * Delete an artifact + */ + async deleteArtifact( + contractId: string, + artifactId: string, + ): Promise { + try { + const artifactDir = path.join( + this.config.baseDir, + this.getArtifactPath(contractId, artifactId), + ); + + if (!fs.existsSync(artifactDir)) { + return { + success: false, + error: `Artifact ${artifactId} not found`, + }; + } + + // Remove directory and contents + fs.rmSync(artifactDir, { recursive: true, force: true }); + + return { + success: true, + artifactId, + details: { deleted: true }, + }; + } catch (error) { + return { + success: false, + error: `Failed to delete artifact: ${error}`, + }; + } + } + + /** + * Clean up expired artifacts + */ + async cleanupExpiredArtifacts(): Promise { + const now = Date.now(); + const expiredMetadata = this.loadAllMetadata().filter((m) => { + if (!m.expiresAt) return false; + return m.expiresAt <= now; + }); + + const results: StorageResult[] = []; + + for (const metadata of expiredMetadata) { + const result = await this.deleteArtifact( + metadata.contractId, + metadata.artifactId, + ); + results.push(result); + } + + const succeeded = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + + return { + total: expiredMetadata.length, + succeeded, + failed, + results, + }; + } + + /** + * Get storage statistics + */ + getStorageStatistics(): StorageStatistics { + const allMetadata = this.loadAllMetadata(); + + if (allMetadata.length === 0) { + return { + totalArtifacts: 0, + totalSizeBytes: 0, + averageSizeBytes: 0, + byType: { + report: 0, + snapshot: 0, + analysis: 0, + metrics: 0, + recommendations: 0, + security_audit: 0, + performance_profile: 0, + }, + byNetwork: { + testnet: 0, + mainnet: 0, + standalone: 0, + }, + byFormat: { + json: 0, + markdown: 0, + html: 0, + csv: 0, + binary: 0, + }, + }; + } + + const stats: StorageStatistics = { + totalArtifacts: allMetadata.length, + totalSizeBytes: 0, + averageSizeBytes: 0, + byType: { + report: 0, + snapshot: 0, + analysis: 0, + metrics: 0, + recommendations: 0, + security_audit: 0, + performance_profile: 0, + }, + byNetwork: { + testnet: 0, + mainnet: 0, + standalone: 0, + }, + byFormat: { + json: 0, + markdown: 0, + html: 0, + csv: 0, + binary: 0, + }, + oldestArtifactAt: Math.min(...allMetadata.map((m) => m.createdAt)), + newestArtifactAt: Math.max(...allMetadata.map((m) => m.createdAt)), + }; + + for (const metadata of allMetadata) { + stats.totalSizeBytes += metadata.sizeBytes; + stats.byType[metadata.artifactType]++; + stats.byNetwork[metadata.network]++; + stats.byFormat[metadata.format]++; + } + + stats.averageSizeBytes = + stats.totalSizeBytes / stats.totalArtifacts; + + return stats; + } + + /** + * Generate artifact path based on contract and artifact ID + */ + private getArtifactPath(contractId: string, artifactId: string): string { + const datePart = new Date(Date.now()).toISOString().split("T")[0]; + return path.join(contractId, datePart, artifactId); + } + + /** + * Backup an existing artifact + */ + private backupArtifact(contractId: string, artifactId: string): void { + try { + const sourcePath = path.join( + this.config.baseDir, + this.getArtifactPath(contractId, artifactId), + ); + const backupTimestamp = Date.now(); + const backupPath = path.join( + this.config.backupDir!, + contractId, + `${artifactId}-${backupTimestamp}`, + ); + + fs.mkdirSync(path.dirname(backupPath), { recursive: true }); + + // Copy the entire artifact directory + this.copyDirectory(sourcePath, backupPath); + } catch (error) { + console.warn(`Failed to backup artifact: ${error}`); + } + } + + /** + * Copy directory recursively + */ + private copyDirectory(src: string, dest: string): void { + fs.mkdirSync(dest, { recursive: true }); + const files = fs.readdirSync(src); + + for (const file of files) { + const srcFile = path.join(src, file); + const destFile = path.join(dest, file); + + if (fs.statSync(srcFile).isDirectory()) { + this.copyDirectory(srcFile, destFile); + } else { + fs.copyFileSync(srcFile, destFile); + } + } + } + + /** + * Load all artifact metadata + */ + private loadAllMetadata(): ArtifactMetadata[] { + try { + if (!fs.existsSync(this.metadataFile)) { + return []; + } + + const content = fs.readFileSync(this.metadataFile, "utf8"); + return JSON.parse(content); + } catch (error) { + console.warn(`Failed to load metadata index: ${error}`); + return []; + } + } + + /** + * Update metadata index + */ + private updateMetadataIndex(metadata: ArtifactMetadata): void { + try { + let allMetadata = this.loadAllMetadata(); + + // Remove existing entry if present + allMetadata = allMetadata.filter( + (m) => + !( + m.artifactId === metadata.artifactId && + m.contractId === metadata.contractId + ), + ); + + // Add new entry + allMetadata.push(metadata); + + // Save updated metadata + fs.writeFileSync( + this.metadataFile, + JSON.stringify(allMetadata, null, 2), + "utf8", + ); + } catch (error) { + console.warn(`Failed to update metadata index: ${error}`); + } + } +} + +export default ArtifactStorageService; diff --git a/src/storage/artifacts/stellar/index.ts b/src/storage/artifacts/stellar/index.ts new file mode 100644 index 0000000..8f161dd --- /dev/null +++ b/src/storage/artifacts/stellar/index.ts @@ -0,0 +1,44 @@ +/** + * Soroban Analysis Artifact Storage Module + * + * Main entry point for artifact storage and retrieval functionality. + * Provides all necessary components for persisting and managing Soroban + * analysis artifacts. + */ + +export * from "./types"; +export { default as ArtifactStorageService } from "./artifact-storage.service"; +export { default as ArtifactRetriever } from "./artifact-retriever"; +export { default as MetadataManager } from "./metadata-manager"; + +import ArtifactStorageService from "./artifact-storage.service"; +import ArtifactRetriever from "./artifact-retriever"; +import MetadataManager from "./metadata-manager"; + +/** + * Initialize the complete artifact storage system + */ +export function initializeArtifactStorage(baseDir: string = "./artifacts") { + const storageService = new ArtifactStorageService({ + baseDir, + enableCompression: false, + maxSizeBytes: 100 * 1024 * 1024, // 100MB max + retentionMs: 90 * 24 * 60 * 60 * 1000, // 90 days + verifyChecksum: true, + createBackups: true, + }); + + const retriever = new ArtifactRetriever(storageService, { + enableCache: true, + cacheTtlMs: 5 * 60 * 1000, // 5 minutes + maxCacheSize: 100, + }); + + const metadataManager = new MetadataManager(baseDir); + + return { + storageService, + retriever, + metadataManager, + }; +} diff --git a/src/storage/artifacts/stellar/metadata-manager.ts b/src/storage/artifacts/stellar/metadata-manager.ts new file mode 100644 index 0000000..159b1a5 --- /dev/null +++ b/src/storage/artifacts/stellar/metadata-manager.ts @@ -0,0 +1,501 @@ +/** + * Soroban Analysis Artifact Metadata Manager + * + * Handles metadata indexing, searching, and management for stored artifacts. + * Provides efficient metadata queries and bulk operations. + */ + +import * as fs from "fs"; +import * as path from "path"; +import { ArtifactMetadata, ArtifactQuery, StorageStatistics } from "./types"; + +/** + * Index entry for fast lookups + */ +interface IndexEntry { + artifactId: string; + contractId: string; + createdAt: number; + artifactType: ArtifactMetadata["artifactType"]; + network: ArtifactMetadata["network"]; +} + +/** + * Manager for artifact metadata and indexing + */ +export class MetadataManager { + private indexPath: string; + private metadataDir: string; + private index: Map = new Map(); + + constructor(baseDir: string = "./artifacts") { + this.metadataDir = baseDir; + this.indexPath = path.join(baseDir, ".index.json"); + this.loadIndex(); + } + + /** + * Register new artifact metadata + */ + registerArtifact(metadata: ArtifactMetadata): void { + const key = this.getMetadataKey( + metadata.contractId, + metadata.artifactId, + ); + + this.index.set(key, { + artifactId: metadata.artifactId, + contractId: metadata.contractId, + createdAt: metadata.createdAt, + artifactType: metadata.artifactType, + network: metadata.network, + }); + + this.saveIndex(); + } + + /** + * Unregister artifact metadata + */ + unregisterArtifact(contractId: string, artifactId: string): void { + const key = this.getMetadataKey(contractId, artifactId); + this.index.delete(key); + this.saveIndex(); + } + + /** + * Get metadata for artifact + */ + getMetadata( + contractId: string, + artifactId: string, + ): ArtifactMetadata | null { + const metadataPath = this.getMetadataPath(contractId, artifactId); + + try { + if (fs.existsSync(metadataPath)) { + const content = fs.readFileSync(metadataPath, "utf8"); + return JSON.parse(content); + } + } catch (error) { + console.warn( + `Failed to load metadata from ${metadataPath}: ${error}`, + ); + } + + return null; + } + + /** + * Update metadata for artifact + */ + updateMetadata(metadata: ArtifactMetadata): void { + const metadataPath = this.getMetadataPath( + metadata.contractId, + metadata.artifactId, + ); + + try { + const dir = path.dirname(metadataPath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + metadataPath, + JSON.stringify(metadata, null, 2), + "utf8", + ); + this.registerArtifact(metadata); + } catch (error) { + throw new Error(`Failed to update metadata: ${error}`); + } + } + + /** + * Find metadata by criteria + */ + findByCriteria(query: Partial): ArtifactMetadata[] { + const results: ArtifactMetadata[] = []; + + for (const entry of this.index.values()) { + let matches = true; + + if ( + query.contractId && + entry.contractId !== query.contractId + ) { + matches = false; + } + if ( + query.artifactType && + entry.artifactType !== query.artifactType + ) { + matches = false; + } + if (query.network && entry.network !== query.network) { + matches = false; + } + + if ( + query.createdAfter && + entry.createdAt < query.createdAfter + ) { + matches = false; + } + if ( + query.createdBefore && + entry.createdAt > query.createdBefore + ) { + matches = false; + } + + if (matches) { + const metadata = this.getMetadata( + entry.contractId, + entry.artifactId, + ); + if (metadata) { + results.push(metadata); + } + } + } + + return results; + } + + /** + * Get all metadata for contract + */ + getContractMetadata(contractId: string): ArtifactMetadata[] { + return this.findByCriteria({ contractId }); + } + + /** + * Get metadata by type + */ + getMetadataByType( + artifactType: ArtifactMetadata["artifactType"], + ): ArtifactMetadata[] { + return this.findByCriteria({ artifactType }); + } + + /** + * Get metadata by network + */ + getMetadataByNetwork( + network: ArtifactMetadata["network"], + ): ArtifactMetadata[] { + return this.findByCriteria({ network }); + } + + /** + * Get metadata by tags + */ + getMetadataByTags(tags: string[]): ArtifactMetadata[] { + const allMetadata = Array.from(this.index.values()).map((entry) => + this.getMetadata(entry.contractId, entry.artifactId), + ); + + return allMetadata.filter((m) => { + if (!m || !m.tags) return false; + return tags.some((tag) => m.tags!.includes(tag)); + }) as ArtifactMetadata[]; + } + + /** + * Get most recent artifacts + */ + getMostRecentArtifacts(limit: number = 10): ArtifactMetadata[] { + const allMetadata = Array.from(this.index.values()) + .map((entry) => this.getMetadata(entry.contractId, entry.artifactId)) + .filter((m) => m !== null) as ArtifactMetadata[]; + + return allMetadata + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, limit); + } + + /** + * Get artifacts by date range + */ + getArtifactsByDateRange( + startTime: number, + endTime: number, + ): ArtifactMetadata[] { + return this.findByCriteria({ + createdAfter: startTime, + createdBefore: endTime, + }); + } + + /** + * Get statistics about stored metadata + */ + getStatistics(): StorageStatistics { + const allMetadata = Array.from(this.index.values()) + .map((entry) => this.getMetadata(entry.contractId, entry.artifactId)) + .filter((m) => m !== null) as ArtifactMetadata[]; + + if (allMetadata.length === 0) { + return { + totalArtifacts: 0, + totalSizeBytes: 0, + averageSizeBytes: 0, + byType: { + report: 0, + snapshot: 0, + analysis: 0, + metrics: 0, + recommendations: 0, + security_audit: 0, + performance_profile: 0, + }, + byNetwork: { + testnet: 0, + mainnet: 0, + standalone: 0, + }, + byFormat: { + json: 0, + markdown: 0, + html: 0, + csv: 0, + binary: 0, + }, + }; + } + + const stats: StorageStatistics = { + totalArtifacts: allMetadata.length, + totalSizeBytes: 0, + averageSizeBytes: 0, + byType: { + report: 0, + snapshot: 0, + analysis: 0, + metrics: 0, + recommendations: 0, + security_audit: 0, + performance_profile: 0, + }, + byNetwork: { + testnet: 0, + mainnet: 0, + standalone: 0, + }, + byFormat: { + json: 0, + markdown: 0, + html: 0, + csv: 0, + binary: 0, + }, + oldestArtifactAt: Math.min( + ...allMetadata.map((m) => m.createdAt), + ), + newestArtifactAt: Math.max( + ...allMetadata.map((m) => m.createdAt), + ), + }; + + for (const metadata of allMetadata) { + stats.totalSizeBytes += metadata.sizeBytes; + stats.byType[metadata.artifactType]++; + stats.byNetwork[metadata.network]++; + stats.byFormat[metadata.format]++; + } + + stats.averageSizeBytes = + stats.totalSizeBytes / stats.totalArtifacts; + + return stats; + } + + /** + * Rebuild index from disk + */ + rebuildIndex(): void { + this.index.clear(); + + try { + const entries = this.scanDirectory(this.metadataDir); + for (const entry of entries) { + const metadata = this.getMetadata( + entry.contractId, + entry.artifactId, + ); + if (metadata) { + this.registerArtifact(metadata); + } + } + } catch (error) { + console.warn(`Failed to rebuild index: ${error}`); + } + } + + /** + * Clean up orphaned metadata files + */ + cleanupOrphanedMetadata(): number { + const entries = this.scanDirectory(this.metadataDir); + let removed = 0; + + for (const entry of entries) { + const key = this.getMetadataKey( + entry.contractId, + entry.artifactId, + ); + + if (!this.index.has(key)) { + try { + const metadataPath = this.getMetadataPath( + entry.contractId, + entry.artifactId, + ); + fs.unlinkSync(metadataPath); + removed++; + } catch (error) { + console.warn( + `Failed to remove orphaned metadata: ${error}`, + ); + } + } + } + + return removed; + } + + /** + * Get metadata file path + */ + private getMetadataPath( + contractId: string, + artifactId: string, + ): string { + const datePart = new Date(Date.now()).toISOString().split("T")[0]; + return path.join( + this.metadataDir, + contractId, + datePart, + artifactId, + "metadata.json", + ); + } + + /** + * Get metadata cache key + */ + private getMetadataKey(contractId: string, artifactId: string): string { + return `${contractId}:${artifactId}`; + } + + /** + * Scan directory for metadata files + */ + private scanDirectory(dir: string): IndexEntry[] { + const entries: IndexEntry[] = []; + + try { + if (!fs.existsSync(dir)) { + return entries; + } + + const walk = (currentPath: string, depth: number = 0) => { + if (depth > 5) return; // Limit recursion + + try { + const files = fs.readdirSync(currentPath); + + for (const file of files) { + if (file === ".metadata.json" || file === ".index.json") { + continue; + } + + const filePath = path.join(currentPath, file); + const stat = fs.statSync(filePath); + + if (stat.isDirectory()) { + walk(filePath, depth + 1); + } else if (file === "metadata.json") { + // Extract contract and artifact IDs from path + const parts = filePath + .replace(dir, "") + .split(path.sep) + .filter((p) => p); + + if (parts.length >= 3) { + const contractId = parts[0]; + const artifactId = parts[2]; + + const metadata = this.getMetadata( + contractId, + artifactId, + ); + if (metadata) { + entries.push({ + artifactId, + contractId, + createdAt: metadata.createdAt, + artifactType: metadata.artifactType, + network: metadata.network, + }); + } + } + } + } + } catch (error) { + console.warn( + `Error scanning directory ${currentPath}: ${error}`, + ); + } + }; + + walk(dir); + } catch (error) { + console.warn(`Failed to scan directory: ${error}`); + } + + return entries; + } + + /** + * Load index from disk + */ + private loadIndex(): void { + try { + if (fs.existsSync(this.indexPath)) { + const content = fs.readFileSync(this.indexPath, "utf8"); + const indexData: IndexEntry[] = JSON.parse(content); + + for (const entry of indexData) { + const key = this.getMetadataKey( + entry.contractId, + entry.artifactId, + ); + this.index.set(key, entry); + } + } + } catch (error) { + console.warn(`Failed to load index: ${error}`); + } + } + + /** + * Save index to disk + */ + private saveIndex(): void { + try { + const dir = path.dirname(this.indexPath); + fs.mkdirSync(dir, { recursive: true }); + + const indexData = Array.from(this.index.values()); + fs.writeFileSync( + this.indexPath, + JSON.stringify(indexData, null, 2), + "utf8", + ); + } catch (error) { + console.warn(`Failed to save index: ${error}`); + } + } +} + +export default MetadataManager; diff --git a/src/storage/artifacts/stellar/types.ts b/src/storage/artifacts/stellar/types.ts new file mode 100644 index 0000000..a368489 --- /dev/null +++ b/src/storage/artifacts/stellar/types.ts @@ -0,0 +1,192 @@ +/** + * Soroban Analysis Artifact Storage Types + * + * Type definitions for persisting generated analysis artifacts including + * reports, metadata, and other analysis outputs for Soroban contracts. + */ + +/** + * Artifact metadata for tracking and retrieval + */ +export interface ArtifactMetadata { + /** Unique artifact identifier */ + artifactId: string; + /** Contract ID/address that generated this artifact */ + contractId: string; + /** Type of artifact (report, snapshot, analysis, etc.) */ + artifactType: + | "report" + | "snapshot" + | "analysis" + | "metrics" + | "recommendations" + | "security_audit" + | "performance_profile"; + /** Format of the artifact (json, markdown, html, etc.) */ + format: "json" | "markdown" | "html" | "csv" | "binary"; + /** Contract version analyzed */ + contractVersion?: string; + /** Network on which contract was analyzed */ + network: "testnet" | "mainnet" | "standalone"; + /** Network passphrase */ + networkPassphrase: string; + /** Ledger sequence at time of analysis */ + ledgerSequence: number; + /** Timestamp when artifact was created */ + createdAt: number; + /** Timestamp when artifact was last updated */ + updatedAt: number; + /** Optional description or notes about the artifact */ + description?: string; + /** Tags for organizing and searching artifacts */ + tags?: string[]; + /** Originating analysis tool or module */ + generatedBy: string; + /** Tool/module version that generated the artifact */ + toolVersion: string; + /** Size in bytes */ + sizeBytes: number; + /** Checksum for integrity verification */ + checksum?: string; + /** Expiration timestamp (optional, for temporary artifacts) */ + expiresAt?: number; + /** Related artifact IDs (dependencies or related outputs) */ + relatedArtifacts?: string[]; +} + +/** + * Artifact content with metadata + */ +export interface Artifact { + /** Artifact metadata */ + metadata: ArtifactMetadata; + /** Artifact content (can be string or object depending on format) */ + content: string | Record; +} + +/** + * Query parameters for retrieving artifacts + */ +export interface ArtifactQuery { + /** Filter by artifact ID */ + artifactId?: string; + /** Filter by contract ID */ + contractId?: string; + /** Filter by artifact type */ + artifactType?: ArtifactMetadata["artifactType"]; + /** Filter by network */ + network?: ArtifactMetadata["network"]; + /** Filter by tags (matches if artifact has any of these tags) */ + tags?: string[]; + /** Filter by creation date range (timestamps in ms) */ + createdAfter?: number; + createdBefore?: number; + /** Filter by tool that generated it */ + generatedBy?: string; + /** Maximum number of results */ + limit?: number; + /** Offset for pagination */ + offset?: number; + /** Sort by field */ + sortBy?: "createdAt" | "updatedAt" | "sizeBytes"; + /** Sort order */ + sortOrder?: "asc" | "desc"; +} + +/** + * Result from artifact storage operations + */ +export interface StorageResult { + /** Operation successful */ + success: boolean; + /** Artifact ID (if applicable) */ + artifactId?: string; + /** File path where artifact was stored */ + filePath?: string; + /** Error message if operation failed */ + error?: string; + /** Additional metadata or details */ + details?: Record; +} + +/** + * Batch operation result + */ +export interface BatchStorageResult { + /** Total items in batch */ + total: number; + /** Successfully stored items */ + succeeded: number; + /** Failed items */ + failed: number; + /** Results for each item */ + results: StorageResult[]; +} + +/** + * Configuration for artifact storage + */ +export interface ArtifactStorageConfig { + /** Base directory for storing artifacts */ + baseDir: string; + /** Whether to enable compression for large artifacts */ + enableCompression: boolean; + /** Maximum artifact size in bytes (0 = unlimited) */ + maxSizeBytes: number; + /** Retention policy - time in ms before automatic deletion (0 = keep forever) */ + retentionMs: number; + /** Whether to verify checksum on retrieval */ + verifyChecksum: boolean; + /** Whether to create backup copies */ + createBackups: boolean; + /** Backup directory */ + backupDir?: string; +} + +/** + * Statistics about stored artifacts + */ +export interface StorageStatistics { + /** Total number of artifacts */ + totalArtifacts: number; + /** Total size of all artifacts in bytes */ + totalSizeBytes: number; + /** Average artifact size in bytes */ + averageSizeBytes: number; + /** Breakdown by artifact type */ + byType: Record; + /** Breakdown by network */ + byNetwork: Record; + /** Breakdown by format */ + byFormat: Record; + /** Oldest artifact timestamp */ + oldestArtifactAt?: number; + /** Most recent artifact timestamp */ + newestArtifactAt?: number; +} + +/** + * Options for storing artifacts + */ +export interface StoreArtifactOptions { + /** Whether to overwrite existing artifact with same ID */ + overwrite?: boolean; + /** Whether to compress the artifact */ + compress?: boolean; + /** Whether to create a backup of replaced artifact */ + backup?: boolean; + /** Custom retention time in ms (overrides default) */ + retentionMs?: number; +} + +/** + * Options for retrieving artifacts + */ +export interface RetrieveArtifactOptions { + /** Whether to decompress if compressed */ + decompress?: boolean; + /** Whether to verify checksum */ + verifyChecksum?: boolean; + /** Return only metadata without content */ + metadataOnly?: boolean; +}