diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d3556558a..97dd6fb62 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -171,6 +171,19 @@ ObjectStack is organized as a **monorepo** with distinct package layers: **Dependencies**: `@objectstack/core`, `@objectstack/spec`, `@objectstack/types` +#### `@objectstack/metadata` +**Location**: `packages/metadata/` +**Role**: Metadata Loading & Persistence + +- Metadata serialization/deserialization (JSON, YAML, TypeScript) +- File system operations (load, save, watch) +- Validation using Zod schemas +- ETag-based caching +- Import/export tools +- SchemaRegistry persistence bridge + +**Dependencies**: `@objectstack/spec`, `@objectstack/core`, `@objectstack/types`, `glob`, `js-yaml`, `chokidar` + #### `@objectstack/runtime` **Location**: `packages/runtime/` **Role**: Runtime Utilities & Plugins @@ -409,6 +422,12 @@ interface PluginCapabilityManifest { │ │ ↑ │ │ └── @objectstack/plugin-msw │ │ + │ ├── @objectstack/metadata (Metadata I/O) + │ │ ↑ + │ │ ├── @objectstack/runtime (Uses for manifest loading) + │ │ ├── @objectstack/cli (Uses for code generation) + │ │ └── @objectstack/objectql (Uses for registry persistence) + │ │ │ ├── @objectstack/runtime (Plugins & HTTP) │ │ ↑ │ │ └── (Used by server plugins) diff --git a/PACKAGE-DEPENDENCIES.md b/PACKAGE-DEPENDENCIES.md index 8f98bbe3c..f91349660 100644 --- a/PACKAGE-DEPENDENCIES.md +++ b/PACKAGE-DEPENDENCIES.md @@ -45,6 +45,16 @@ This is the foundation layer. All other packages depend on `@objectstack/spec`. │ └── @objectstack/types └── Role: ObjectQL query engine +@objectstack/metadata +├── Dependencies: +│ ├── @objectstack/core +│ ├── @objectstack/spec +│ ├── @objectstack/types +│ ├── glob +│ ├── js-yaml +│ └── chokidar (optional) +└── Role: Metadata loading, saving, and persistence + @objectstack/runtime ├── Dependencies: │ ├── @objectstack/core @@ -131,6 +141,12 @@ This is the foundation layer. All other packages depend on `@objectstack/spec`. │ │ ↑ │ │ └── @objectstack/plugin-msw (Layer 5) │ │ + │ ├── @objectstack/metadata (Layer 3) + │ │ ↑ + │ │ ├── @objectstack/runtime (Layer 3) + │ │ ├── @objectstack/cli (Layer 6) + │ │ └── (Future integrations) + │ │ │ ├── @objectstack/runtime (Layer 3) │ │ ↑ │ │ └── (Used by server plugins) @@ -156,6 +172,7 @@ This is the foundation layer. All other packages depend on `@objectstack/spec`. | `@objectstack/types` | `@objectstack/spec` | | `@objectstack/core` | `@objectstack/spec`, `pino`, `zod` | | `@objectstack/objectql` | `@objectstack/core`, `@objectstack/spec`, `@objectstack/types` | +| `@objectstack/metadata` | `@objectstack/core`, `@objectstack/spec`, `@objectstack/types`, `glob`, `js-yaml`, `chokidar` | | `@objectstack/runtime` | `@objectstack/core`, `@objectstack/spec`, `@objectstack/types` | | `@objectstack/client` | `@objectstack/core`, `@objectstack/spec` | | `@objectstack/client-react` | `@objectstack/client`, `@objectstack/core`, `@objectstack/spec`, `react` (peer) | @@ -181,6 +198,7 @@ pnpm --filter @objectstack/core build # Level 3 pnpm --filter @objectstack/objectql build +pnpm --filter @objectstack/metadata build pnpm --filter @objectstack/runtime build # Level 4 @@ -253,7 +271,8 @@ Use peer dependencies for: |---------|---------------|-------------------| | `@objectstack/spec` | Define protocols | Runtime behavior | | `@objectstack/core` | Manage plugin lifecycle | Query execution, HTTP handling | -| `@objectstack/objectql` | Execute queries | HTTP routing, UI rendering | +| `@objectstack/objectql` | Execute queries | HTTP routing, UI rendering, Metadata persistence | +| `@objectstack/metadata` | Load/save metadata | Query execution, Plugin lifecycle | | `@objectstack/runtime` | Provide plugin utilities | Execute queries directly | ## External Dependencies diff --git a/content/docs/references/meta.json b/content/docs/references/meta.json index 154d7fd6a..577f212af 100644 --- a/content/docs/references/meta.json +++ b/content/docs/references/meta.json @@ -1,7 +1,6 @@ { "title": "Protocol Reference", "pages": [ - "packages", "data", "ui", "automation", @@ -9,10 +8,6 @@ "permission", "ai", "api", - "driver", - "hub", - "integration", - "auth", - "shared" + "driver" ] } \ No newline at end of file diff --git a/content/docs/references/system/index.mdx b/content/docs/references/system/index.mdx index ce719f6ac..a82ccf6b1 100644 --- a/content/docs/references/system/index.mdx +++ b/content/docs/references/system/index.mdx @@ -24,6 +24,7 @@ This section contains all protocol schemas for the system layer of ObjectStack. + diff --git a/content/docs/references/system/meta.json b/content/docs/references/system/meta.json index 3c2db1762..e70c88158 100644 --- a/content/docs/references/system/meta.json +++ b/content/docs/references/system/meta.json @@ -17,6 +17,7 @@ "manifest", "masking", "message-queue", + "metadata-loader", "metrics", "notification", "object-storage", diff --git a/examples/msw-react-crud/src/components/TaskList.tsx b/examples/msw-react-crud/src/components/TaskList.tsx index eef7b9d5a..6a76b8a0e 100644 --- a/examples/msw-react-crud/src/components/TaskList.tsx +++ b/examples/msw-react-crud/src/components/TaskList.tsx @@ -36,8 +36,8 @@ export function TaskList({ client, onEdit, refreshTrigger }: TaskListProps) { sort: ['priority', '-created_at'] }); - // Handle { value: [] } (OData), { records: [] } (Protocol) and [] (Raw) formats - const rawValues = Array.isArray(result) ? result : (result.value || result.records || []); + // Handle { value: [] } (PaginatedResult) and [] (Raw) formats + const rawValues = Array.isArray(result) ? result : (result.value || []); const fetchedTasks = [...rawValues] as Task[]; // Client-side sort fallback (since InMemoryDriver has limited sort support) diff --git a/packages/metadata/README.md b/packages/metadata/README.md new file mode 100644 index 000000000..da4366b7d --- /dev/null +++ b/packages/metadata/README.md @@ -0,0 +1,201 @@ +# @objectstack/metadata + +Metadata loading, saving, and persistence for ObjectStack. + +## Overview + +The `@objectstack/metadata` package provides a unified interface for managing metadata across the ObjectStack ecosystem. It handles: + +- **Metadata Serialization/Deserialization** - Support for JSON, YAML, TypeScript, and JavaScript formats +- **File System Operations** - Load, save, and watch metadata files +- **Validation** - Integration with Zod schemas from `@objectstack/spec` +- **Caching** - ETag-based caching for performance optimization +- **File Watching** - Development mode with automatic reload on file changes + +## Installation + +```bash +pnpm add @objectstack/metadata +``` + +## Quick Start + +```typescript +import { MetadataManager } from '@objectstack/metadata'; +import type { ServiceObject } from '@objectstack/spec/data'; + +// Create manager +const manager = new MetadataManager({ + rootDir: './metadata', + formats: ['typescript', 'json', 'yaml'], + cache: { enabled: true, ttl: 3600 }, + watch: process.env.NODE_ENV === 'development', +}); + +// Load metadata +const customer = await manager.load('object', 'customer'); + +// Save metadata +await manager.save('object', 'project', projectObject, { + format: 'typescript', + prettify: true, +}); + +// Load multiple items +const objects = await manager.loadMany('object', { + patterns: ['**/*.object.ts', '**/*.object.json'], +}); + +// Watch for changes +manager.watch('object', (event) => { + console.log(`Object ${event.type}:`, event.name); +}); +``` + +## API + +### MetadataManager + +Main class for metadata operations. + +#### Constructor + +```typescript +new MetadataManager(config: MetadataManagerConfig) +``` + +**Config options:** +- `rootDir` - Root directory for metadata files +- `formats` - Enabled serialization formats (default: `['typescript', 'json', 'yaml']`) +- `cache` - Cache configuration with `enabled` and `ttl` options +- `watch` - Enable file watching (default: `false`) +- `watchOptions` - File watcher options (`ignored`, `persistent`, `ignoreInitial`) + +#### Methods + +**load(type: string, name: string, options?: MetadataLoadOptions): Promise** + +Load a single metadata item. + +```typescript +const customer = await manager.load('object', 'customer'); +``` + +**loadMany(type: string, options?: MetadataLoadOptions): Promise** + +Load multiple metadata items matching patterns. + +```typescript +const objects = await manager.loadMany('object', { + patterns: ['**/*.object.ts'], + limit: 100, +}); +``` + +**save(type: string, name: string, data: T, options?: MetadataSaveOptions): Promise** + +Save metadata to disk. + +```typescript +await manager.save('object', 'customer', customerObject, { + format: 'typescript', + prettify: true, + backup: true, +}); +``` + +**exists(type: string, name: string): Promise** + +Check if metadata item exists. + +```typescript +const exists = await manager.exists('object', 'customer'); +``` + +**list(type: string): Promise** + +List all items of a type. + +```typescript +const objectNames = await manager.list('object'); +``` + +**watch(type: string, callback: WatchCallback): void** + +Watch for metadata changes. + +```typescript +manager.watch('object', (event) => { + if (event.type === 'added') { + console.log('New object:', event.name); + } +}); +``` + +**stopWatching(): Promise** + +Stop all file watching. + +## Serialization Formats + +### JSON + +```json +{ + "name": "customer", + "label": "Customer", + "fields": { + "name": { "type": "text", "label": "Name" } + } +} +``` + +### YAML + +```yaml +name: customer +label: Customer +fields: + name: + type: text + label: Name +``` + +### TypeScript + +```typescript +import type { ServiceObject } from '@objectstack/spec/data'; + +export const metadata: ServiceObject = { + name: 'customer', + label: 'Customer', + fields: { + name: { type: 'text', label: 'Name' }, + }, +}; + +export default metadata; +``` + +## Architecture + +The metadata package is designed as a Layer 3 package in the ObjectStack architecture: + +``` +@objectstack/metadata (Layer 3) +├── Dependencies: +│ ├── @objectstack/spec (validation) +│ ├── @objectstack/core (logging, DI) +│ ├── @objectstack/types (shared types) +│ ├── glob (file pattern matching) +│ ├── js-yaml (YAML support) +│ └── chokidar (file watching) +└── Used By: + ├── @objectstack/cli (code generation) + ├── @objectstack/runtime (manifest loading) + └── @objectstack/objectql (registry persistence) +``` + +## License + +MIT diff --git a/packages/metadata/package.json b/packages/metadata/package.json new file mode 100644 index 000000000..16c428c7a --- /dev/null +++ b/packages/metadata/package.json @@ -0,0 +1,37 @@ +{ + "name": "@objectstack/metadata", + "version": "0.6.1", + "description": "Metadata loading, saving, and persistence for ObjectStack", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "clean": "rm -rf dist", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "keywords": [ + "objectstack", + "metadata", + "persistence", + "serialization", + "loader" + ], + "dependencies": { + "@objectstack/core": "workspace:*", + "@objectstack/spec": "workspace:*", + "@objectstack/types": "workspace:*", + "glob": "^10.3.10", + "js-yaml": "^4.1.0", + "chokidar": "^3.5.3", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.0.0", + "typescript": "^5.0.0", + "vitest": "^1.0.0" + } +} diff --git a/packages/metadata/src/index.ts b/packages/metadata/src/index.ts new file mode 100644 index 000000000..21b0ecc82 --- /dev/null +++ b/packages/metadata/src/index.ts @@ -0,0 +1,34 @@ +/** + * @objectstack/metadata + * + * Metadata loading, saving, and persistence for ObjectStack + */ + +// Main Manager +export { MetadataManager, type WatchCallback } from './metadata-manager.js'; + +// Loaders +export { type MetadataLoader } from './loaders/loader-interface.js'; +export { FilesystemLoader } from './loaders/filesystem-loader.js'; + +// Serializers +export { type MetadataSerializer, type SerializeOptions } from './serializers/serializer-interface.js'; +export { JSONSerializer } from './serializers/json-serializer.js'; +export { YAMLSerializer } from './serializers/yaml-serializer.js'; +export { TypeScriptSerializer } from './serializers/typescript-serializer.js'; + +// Re-export types from spec +export type { + MetadataFormat, + MetadataStats, + MetadataLoadOptions, + MetadataSaveOptions, + MetadataExportOptions, + MetadataImportOptions, + MetadataLoadResult, + MetadataSaveResult, + MetadataWatchEvent, + MetadataCollectionInfo, + MetadataLoaderContract, + MetadataManagerConfig, +} from '@objectstack/spec/system'; diff --git a/packages/metadata/src/loaders/filesystem-loader.ts b/packages/metadata/src/loaders/filesystem-loader.ts new file mode 100644 index 000000000..e668957f6 --- /dev/null +++ b/packages/metadata/src/loaders/filesystem-loader.ts @@ -0,0 +1,314 @@ +/** + * Filesystem Metadata Loader + * + * Loads metadata from the filesystem using glob patterns + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { glob } from 'glob'; +import { createHash } from 'node:crypto'; +import type { + MetadataLoadOptions, + MetadataLoadResult, + MetadataStats, + MetadataLoaderContract, + MetadataFormat, +} from '@objectstack/spec/system'; +import type { Logger } from '@objectstack/core'; +import type { MetadataLoader } from './loader-interface.js'; +import type { MetadataSerializer } from '../serializers/serializer-interface.js'; + +export class FilesystemLoader implements MetadataLoader { + readonly contract: MetadataLoaderContract = { + name: 'filesystem', + supportedFormats: ['json', 'yaml', 'typescript', 'javascript'], + supportsWatch: true, + supportsWrite: true, + supportsCache: true, + }; + + private cache = new Map(); + + constructor( + private rootDir: string, + private serializers: Map, + private logger?: Logger + ) {} + + async load( + type: string, + name: string, + options?: MetadataLoadOptions + ): Promise { + const startTime = Date.now(); + const { validate: _validate = true, useCache = true, ifNoneMatch } = options || {}; + + try { + // Find the file + const filePath = await this.findFile(type, name); + + if (!filePath) { + return { + data: null, + fromCache: false, + notModified: false, + loadTime: Date.now() - startTime, + }; + } + + // Get stats + const stats = await this.stat(type, name); + + if (!stats) { + return { + data: null, + fromCache: false, + notModified: false, + loadTime: Date.now() - startTime, + }; + } + + // Check cache + if (useCache && ifNoneMatch && stats.etag === ifNoneMatch) { + return { + data: null, + fromCache: true, + notModified: true, + etag: stats.etag, + stats, + loadTime: Date.now() - startTime, + }; + } + + // Check memory cache + const cacheKey = `${type}:${name}`; + if (useCache && this.cache.has(cacheKey)) { + const cached = this.cache.get(cacheKey)!; + if (cached.etag === stats.etag) { + return { + data: cached.data, + fromCache: true, + notModified: false, + etag: stats.etag, + stats, + loadTime: Date.now() - startTime, + }; + } + } + + // Load and deserialize + const content = await fs.readFile(filePath, 'utf-8'); + const serializer = this.getSerializer(stats.format); + + if (!serializer) { + throw new Error(`No serializer found for format: ${stats.format}`); + } + + const data = serializer.deserialize(content); + + // Update cache + if (useCache) { + this.cache.set(cacheKey, { + data, + etag: stats.etag, + timestamp: Date.now(), + }); + } + + return { + data, + fromCache: false, + notModified: false, + etag: stats.etag, + stats, + loadTime: Date.now() - startTime, + }; + } catch (error) { + this.logger?.error('Failed to load metadata', undefined, { + type, + name, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async loadMany( + type: string, + options?: MetadataLoadOptions + ): Promise { + const { patterns = ['**/*'], recursive: _recursive = true, limit } = options || {}; + + const typeDir = path.join(this.rootDir, type); + const items: T[] = []; + + try { + // Build glob patterns + const globPatterns = patterns.map(pattern => + path.join(typeDir, pattern) + ); + + for (const pattern of globPatterns) { + const files = await glob(pattern, { + ignore: ['**/node_modules/**', '**/*.test.*', '**/*.spec.*'], + nodir: true, + }); + + for (const file of files) { + if (limit && items.length >= limit) { + break; + } + + try { + const content = await fs.readFile(file, 'utf-8'); + const format = this.detectFormat(file); + const serializer = this.getSerializer(format); + + if (serializer) { + const data = serializer.deserialize(content); + items.push(data); + } + } catch (error) { + this.logger?.warn('Failed to load file', { + file, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (limit && items.length >= limit) { + break; + } + } + + return items; + } catch (error) { + this.logger?.error('Failed to load many', undefined, { + type, + patterns, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + async exists(type: string, name: string): Promise { + const filePath = await this.findFile(type, name); + return filePath !== null; + } + + async stat(type: string, name: string): Promise { + const filePath = await this.findFile(type, name); + + if (!filePath) { + return null; + } + + try { + const stats = await fs.stat(filePath); + const content = await fs.readFile(filePath, 'utf-8'); + const etag = this.generateETag(content); + const format = this.detectFormat(filePath); + + return { + size: stats.size, + modifiedAt: stats.mtime, + etag, + format, + path: filePath, + }; + } catch (error) { + this.logger?.error('Failed to stat file', undefined, { + type, + name, + filePath, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + + async list(type: string): Promise { + const typeDir = path.join(this.rootDir, type); + + try { + const files = await glob('**/*', { + cwd: typeDir, + ignore: ['**/node_modules/**', '**/*.test.*', '**/*.spec.*'], + nodir: true, + }); + + return files.map(file => { + const ext = path.extname(file); + const basename = path.basename(file, ext); + return basename; + }); + } catch (error) { + this.logger?.error('Failed to list', undefined, { + type, + error: error instanceof Error ? error.message : String(error), + }); + return []; + } + } + + /** + * Find file for a given type and name + */ + private async findFile(type: string, name: string): Promise { + const typeDir = path.join(this.rootDir, type); + const extensions = ['.json', '.yaml', '.yml', '.ts', '.js']; + + for (const ext of extensions) { + const filePath = path.join(typeDir, `${name}${ext}`); + + try { + await fs.access(filePath); + return filePath; + } catch { + // File doesn't exist, try next extension + } + } + + return null; + } + + /** + * Detect format from file extension + */ + private detectFormat(filePath: string): MetadataFormat { + const ext = path.extname(filePath).toLowerCase(); + + switch (ext) { + case '.json': + return 'json'; + case '.yaml': + case '.yml': + return 'yaml'; + case '.ts': + return 'typescript'; + case '.js': + return 'javascript'; + default: + return 'json'; // Default to JSON + } + } + + /** + * Get serializer for format + */ + private getSerializer(format: MetadataFormat): MetadataSerializer | undefined { + return this.serializers.get(format); + } + + /** + * Generate ETag for content + * Uses SHA-256 hash truncated to 32 characters for reasonable collision resistance + * while keeping ETag headers compact (full 64-char hash is overkill for this use case) + */ + private generateETag(content: string): string { + const hash = createHash('sha256').update(content).digest('hex').substring(0, 32); + return `"${hash}"`; + } +} diff --git a/packages/metadata/src/loaders/loader-interface.ts b/packages/metadata/src/loaders/loader-interface.ts new file mode 100644 index 000000000..8a78261ef --- /dev/null +++ b/packages/metadata/src/loaders/loader-interface.ts @@ -0,0 +1,70 @@ +/** + * Metadata Loader Interface + * + * Defines the contract for loading metadata from various sources + */ + +import type { + MetadataLoadOptions, + MetadataLoadResult, + MetadataStats, + MetadataLoaderContract, +} from '@objectstack/spec/system'; + +/** + * Abstract interface for metadata loaders + * Implementations can load from filesystem, HTTP, S3, databases, etc. + */ +export interface MetadataLoader { + /** + * Loader contract information + */ + readonly contract: MetadataLoaderContract; + + /** + * Load a single metadata item + * @param type The metadata type (e.g., 'object', 'view', 'app') + * @param name The item name/identifier + * @param options Load options + * @returns Load result with data or null if not found + */ + load( + type: string, + name: string, + options?: MetadataLoadOptions + ): Promise; + + /** + * Load multiple items matching patterns + * @param type The metadata type + * @param options Load options with patterns + * @returns Array of loaded items + */ + loadMany( + type: string, + options?: MetadataLoadOptions + ): Promise; + + /** + * Check if item exists + * @param type The metadata type + * @param name The item name + * @returns True if exists + */ + exists(type: string, name: string): Promise; + + /** + * Get item metadata (without loading full content) + * @param type The metadata type + * @param name The item name + * @returns Metadata statistics + */ + stat(type: string, name: string): Promise; + + /** + * List all items of a type + * @param type The metadata type + * @returns Array of item names + */ + list(type: string): Promise; +} diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts new file mode 100644 index 000000000..245eb9315 --- /dev/null +++ b/packages/metadata/src/metadata-manager.ts @@ -0,0 +1,338 @@ +/** + * Metadata Manager + * + * Main orchestrator for metadata loading, saving, and persistence + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { createHash } from 'node:crypto'; +import { watch as chokidarWatch, type FSWatcher } from 'chokidar'; +import type { + MetadataManagerConfig, + MetadataLoadOptions, + MetadataSaveOptions, + MetadataSaveResult, + MetadataWatchEvent, + MetadataFormat, +} from '@objectstack/spec/system'; +import { createLogger, type Logger } from '@objectstack/core'; +import { FilesystemLoader } from './loaders/filesystem-loader.js'; +import { JSONSerializer } from './serializers/json-serializer.js'; +import { YAMLSerializer } from './serializers/yaml-serializer.js'; +import { TypeScriptSerializer } from './serializers/typescript-serializer.js'; +import type { MetadataSerializer } from './serializers/serializer-interface.js'; +import type { MetadataLoader } from './loaders/loader-interface.js'; + +/** + * Watch callback function + */ +export type WatchCallback = (event: MetadataWatchEvent) => void | Promise; + +/** + * Main metadata manager class + */ +export class MetadataManager { + private loader: MetadataLoader; + private serializers: Map; + private logger: Logger; + private watcher?: FSWatcher; + private watchCallbacks = new Map>(); + + constructor(private config: MetadataManagerConfig) { + this.logger = createLogger({ level: 'info', format: 'pretty' }); + + // Initialize serializers + this.serializers = new Map(); + const formats = config.formats || ['typescript', 'json', 'yaml']; + + if (formats.includes('json')) { + this.serializers.set('json', new JSONSerializer()); + } + if (formats.includes('yaml')) { + this.serializers.set('yaml', new YAMLSerializer()); + } + if (formats.includes('typescript')) { + this.serializers.set('typescript', new TypeScriptSerializer('typescript')); + } + if (formats.includes('javascript')) { + this.serializers.set('javascript', new TypeScriptSerializer('javascript')); + } + + // Initialize loader + const rootDir = config.rootDir || process.cwd(); + this.loader = new FilesystemLoader(rootDir, this.serializers, this.logger); + + // Start watching if enabled + if (config.watch) { + this.startWatching(); + } + } + + /** + * Load a single metadata item + */ + async load( + type: string, + name: string, + options?: MetadataLoadOptions + ): Promise { + const result = await this.loader.load(type, name, options); + return result.data; + } + + /** + * Load multiple metadata items + */ + async loadMany( + type: string, + options?: MetadataLoadOptions + ): Promise { + return this.loader.loadMany(type, options); + } + + /** + * Save metadata to disk + */ + async save( + type: string, + name: string, + data: T, + options?: MetadataSaveOptions + ): Promise { + const startTime = Date.now(); + const { + format = 'typescript', + prettify = true, + indent = 2, + sortKeys = false, + backup = false, + overwrite = true, + atomic = true, + path: customPath, + } = options || {}; + + try { + // Get serializer + const serializer = this.serializers.get(format); + if (!serializer) { + throw new Error(`No serializer found for format: ${format}`); + } + + // Determine file path + const typeDir = path.join(this.config.rootDir || process.cwd(), type); + const fileName = `${name}${serializer.getExtension()}`; + const filePath = customPath || path.join(typeDir, fileName); + + // Check if file exists + if (!overwrite) { + try { + await fs.access(filePath); + throw new Error(`File already exists: ${filePath}`); + } catch (error) { + // File doesn't exist, continue + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + } + } + + // Create directory if it doesn't exist + await fs.mkdir(path.dirname(filePath), { recursive: true }); + + // Create backup if requested + let backupPath: string | undefined; + if (backup) { + try { + await fs.access(filePath); + backupPath = `${filePath}.bak`; + await fs.copyFile(filePath, backupPath); + } catch { + // File doesn't exist, no backup needed + } + } + + // Serialize data + const content = serializer.serialize(data, { + prettify, + indent, + sortKeys, + }); + + // Write to disk (atomic or direct) + if (atomic) { + const tempPath = `${filePath}.tmp`; + await fs.writeFile(tempPath, content, 'utf-8'); + await fs.rename(tempPath, filePath); + } else { + await fs.writeFile(filePath, content, 'utf-8'); + } + + // Get stats + const stats = await fs.stat(filePath); + const etag = this.generateETag(content); + + return { + success: true, + path: filePath, + etag, + size: stats.size, + saveTime: Date.now() - startTime, + backupPath, + }; + } catch (error) { + this.logger.error('Failed to save metadata', undefined, { + type, + name, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + + /** + * Check if metadata item exists + */ + async exists(type: string, name: string): Promise { + return this.loader.exists(type, name); + } + + /** + * List all items of a type + */ + async list(type: string): Promise { + return this.loader.list(type); + } + + /** + * Watch for metadata changes + */ + watch(type: string, callback: WatchCallback): void { + if (!this.watchCallbacks.has(type)) { + this.watchCallbacks.set(type, new Set()); + } + this.watchCallbacks.get(type)!.add(callback); + } + + /** + * Unwatch metadata changes + */ + unwatch(type: string, callback: WatchCallback): void { + const callbacks = this.watchCallbacks.get(type); + if (callbacks) { + callbacks.delete(callback); + if (callbacks.size === 0) { + this.watchCallbacks.delete(type); + } + } + } + + /** + * Stop all watching + */ + async stopWatching(): Promise { + if (this.watcher) { + await this.watcher.close(); + this.watcher = undefined; + this.watchCallbacks.clear(); + } + } + + /** + * Start watching for file changes + */ + private startWatching(): void { + const rootDir = this.config.rootDir || process.cwd(); + const { ignored = ['**/node_modules/**', '**/*.test.*'], persistent = true } = + this.config.watchOptions || {}; + + this.watcher = chokidarWatch(rootDir, { + ignored, + persistent, + ignoreInitial: true, + }); + + this.watcher.on('add', async (filePath) => { + await this.handleFileEvent('added', filePath); + }); + + this.watcher.on('change', async (filePath) => { + await this.handleFileEvent('changed', filePath); + }); + + this.watcher.on('unlink', async (filePath) => { + await this.handleFileEvent('deleted', filePath); + }); + + this.logger.info('File watcher started', { rootDir }); + } + + /** + * Handle file change events + */ + private async handleFileEvent( + eventType: 'added' | 'changed' | 'deleted', + filePath: string + ): Promise { + const rootDir = this.config.rootDir || process.cwd(); + const relativePath = path.relative(rootDir, filePath); + const parts = relativePath.split(path.sep); + + if (parts.length < 2) { + return; // Not a metadata file + } + + const type = parts[0]; + const fileName = parts[parts.length - 1]; + const name = path.basename(fileName, path.extname(fileName)); + + const callbacks = this.watchCallbacks.get(type); + if (!callbacks || callbacks.size === 0) { + return; + } + + let data: any = undefined; + if (eventType !== 'deleted') { + try { + data = await this.load(type, name, { useCache: false }); + } catch (error) { + this.logger.error('Failed to load changed file', undefined, { + filePath, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + } + + const event: MetadataWatchEvent = { + type: eventType, + metadataType: type, + name, + path: filePath, + data, + timestamp: new Date(), + }; + + for (const callback of callbacks) { + try { + await callback(event); + } catch (error) { + this.logger.error('Watch callback error', undefined, { + type, + name, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + + /** + * Generate ETag for content + * Uses SHA-256 hash truncated to 32 characters for reasonable collision resistance + * while keeping ETag headers compact (full 64-char hash is overkill for this use case) + */ + private generateETag(content: string): string { + const hash = createHash('sha256').update(content).digest('hex').substring(0, 32); + return `"${hash}"`; + } +} diff --git a/packages/metadata/src/serializers/json-serializer.ts b/packages/metadata/src/serializers/json-serializer.ts new file mode 100644 index 000000000..be305ee06 --- /dev/null +++ b/packages/metadata/src/serializers/json-serializer.ts @@ -0,0 +1,71 @@ +/** + * JSON Metadata Serializer + * + * Handles JSON format serialization and deserialization + */ + +import type { z } from 'zod'; +import type { MetadataFormat } from '@objectstack/spec/system'; +import type { MetadataSerializer, SerializeOptions } from './serializer-interface.js'; + +export class JSONSerializer implements MetadataSerializer { + serialize(item: T, options?: SerializeOptions): string { + const { prettify = true, indent = 2, sortKeys = false } = options || {}; + + if (sortKeys) { + // Sort keys recursively + const sorted = this.sortObjectKeys(item); + return prettify + ? JSON.stringify(sorted, null, indent) + : JSON.stringify(sorted); + } + + return prettify + ? JSON.stringify(item, null, indent) + : JSON.stringify(item); + } + + deserialize(content: string, schema?: z.ZodSchema): T { + const parsed = JSON.parse(content); + + if (schema) { + return schema.parse(parsed) as T; + } + + return parsed as T; + } + + getExtension(): string { + return '.json'; + } + + canHandle(format: MetadataFormat): boolean { + return format === 'json'; + } + + getFormat(): MetadataFormat { + return 'json'; + } + + /** + * Recursively sort object keys + */ + private sortObjectKeys(obj: any): any { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => this.sortObjectKeys(item)); + } + + const sorted: Record = {}; + const keys = Object.keys(obj).sort(); + + for (const key of keys) { + sorted[key] = this.sortObjectKeys(obj[key]); + } + + return sorted; + } +} diff --git a/packages/metadata/src/serializers/serializer-interface.ts b/packages/metadata/src/serializers/serializer-interface.ts new file mode 100644 index 000000000..27ef295a4 --- /dev/null +++ b/packages/metadata/src/serializers/serializer-interface.ts @@ -0,0 +1,63 @@ +/** + * Metadata Serializer Interface + * + * Defines the contract for serializing/deserializing metadata + */ + +import type { z } from 'zod'; +import type { MetadataFormat } from '@objectstack/spec/system'; + +/** + * Serialization options + */ +export interface SerializeOptions { + /** Prettify output (formatted with indentation) */ + prettify?: boolean; + /** Indentation size (spaces) */ + indent?: number; + /** Sort object keys alphabetically */ + sortKeys?: boolean; + /** Include default values in output */ + includeDefaults?: boolean; +} + +/** + * Abstract interface for metadata serializers + * Implementations handle different formats (JSON, YAML, TypeScript, etc.) + */ +export interface MetadataSerializer { + /** + * Serialize object to string + * @param item The item to serialize + * @param options Serialization options + * @returns Serialized string + */ + serialize(item: T, options?: SerializeOptions): string; + + /** + * Deserialize string to object + * @param content The content to deserialize + * @param schema Optional Zod schema for validation + * @returns Deserialized object + */ + deserialize(content: string, schema?: z.ZodSchema): T; + + /** + * Get file extension for this format + * @returns File extension (e.g., '.json', '.yaml') + */ + getExtension(): string; + + /** + * Check if this serializer can handle the format + * @param format The format to check + * @returns True if can handle + */ + canHandle(format: MetadataFormat): boolean; + + /** + * Get the format this serializer handles + * @returns The metadata format + */ + getFormat(): MetadataFormat; +} diff --git a/packages/metadata/src/serializers/serializers.test.ts b/packages/metadata/src/serializers/serializers.test.ts new file mode 100644 index 000000000..9533a416a --- /dev/null +++ b/packages/metadata/src/serializers/serializers.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { JSONSerializer } from '../serializers/json-serializer'; +import { YAMLSerializer } from '../serializers/yaml-serializer'; +import { TypeScriptSerializer } from '../serializers/typescript-serializer'; + +describe('Serializers', () => { + describe('JSONSerializer', () => { + const serializer = new JSONSerializer(); + + it('should serialize to JSON', () => { + const data = { name: 'test', value: 42 }; + const result = serializer.serialize(data); + expect(result).toContain('"name"'); + expect(result).toContain('"test"'); + }); + + it('should deserialize from JSON', () => { + const json = '{"name":"test","value":42}'; + const result = serializer.deserialize(json); + expect(result).toEqual({ name: 'test', value: 42 }); + }); + + it('should prettify JSON', () => { + const data = { name: 'test' }; + const result = serializer.serialize(data, { prettify: true, indent: 2 }); + expect(result).toContain('\n'); + expect(result).toContain(' '); + }); + + it('should sort keys', () => { + const data = { zebra: 1, apple: 2, banana: 3 }; + const result = serializer.serialize(data, { sortKeys: true }); + const keys = Object.keys(JSON.parse(result)); + expect(keys).toEqual(['apple', 'banana', 'zebra']); + }); + }); + + describe('YAMLSerializer', () => { + const serializer = new YAMLSerializer(); + + it('should serialize to YAML', () => { + const data = { name: 'test', value: 42 }; + const result = serializer.serialize(data); + expect(result).toContain('name: test'); + expect(result).toContain('value: 42'); + }); + + it('should deserialize from YAML', () => { + const yaml = 'name: test\nvalue: 42'; + const result = serializer.deserialize(yaml); + expect(result).toEqual({ name: 'test', value: 42 }); + }); + }); + + describe('TypeScriptSerializer', () => { + const serializer = new TypeScriptSerializer('typescript'); + + it('should serialize to TypeScript module', () => { + const data = { name: 'test', value: 42 }; + const result = serializer.serialize(data); + expect(result).toContain('import type'); + expect(result).toContain('export const metadata'); + expect(result).toContain('export default metadata'); + }); + + it('should get correct extension', () => { + const ts = new TypeScriptSerializer('typescript'); + expect(ts.getExtension()).toBe('.ts'); + + const js = new TypeScriptSerializer('javascript'); + expect(js.getExtension()).toBe('.js'); + }); + }); +}); diff --git a/packages/metadata/src/serializers/typescript-serializer.ts b/packages/metadata/src/serializers/typescript-serializer.ts new file mode 100644 index 000000000..c49d92245 --- /dev/null +++ b/packages/metadata/src/serializers/typescript-serializer.ts @@ -0,0 +1,125 @@ +/** + * TypeScript/JavaScript Metadata Serializer + * + * Handles TypeScript/JavaScript module format serialization and deserialization + */ + +import type { z } from 'zod'; +import type { MetadataFormat } from '@objectstack/spec/system'; +import type { MetadataSerializer, SerializeOptions } from './serializer-interface.js'; + +export class TypeScriptSerializer implements MetadataSerializer { + constructor(private format: 'typescript' | 'javascript' = 'typescript') {} + + serialize(item: T, options?: SerializeOptions): string { + const { prettify = true, indent = 2 } = options || {}; + + const jsonStr = JSON.stringify(item, null, prettify ? indent : 0); + + if (this.format === 'typescript') { + return `import type { ServiceObject } from '@objectstack/spec/data';\n\n` + + `export const metadata: ServiceObject = ${jsonStr};\n\n` + + `export default metadata;\n`; + } else { + return `export const metadata = ${jsonStr};\n\n` + + `export default metadata;\n`; + } + } + + deserialize(content: string, schema?: z.ZodSchema): T { + // For TypeScript/JavaScript files, we need to extract the exported object + // Note: This is a simplified parser that works with JSON-like object literals + // For complex TypeScript with nested objects, consider using a proper TypeScript parser + + // Try to find the object literal in various export patterns + // Pattern 1: export const metadata = {...}; + let objectStart = content.indexOf('export const'); + if (objectStart === -1) { + // Pattern 2: export default {...}; + objectStart = content.indexOf('export default'); + } + + if (objectStart === -1) { + throw new Error( + 'Could not parse TypeScript/JavaScript module. ' + + 'Expected export pattern: "export const metadata = {...};" or "export default {...};"' + ); + } + + // Find the first opening brace after the export statement + const braceStart = content.indexOf('{', objectStart); + if (braceStart === -1) { + throw new Error('Could not find object literal in export statement'); + } + + // Find the matching closing brace by counting braces + // Handle string literals to avoid counting braces inside strings + let braceCount = 0; + let braceEnd = -1; + let inString = false; + let stringChar = ''; + + for (let i = braceStart; i < content.length; i++) { + const char = content[i]; + const prevChar = i > 0 ? content[i - 1] : ''; + + // Track string literals (simple handling of " and ') + if ((char === '"' || char === "'") && prevChar !== '\\') { + if (!inString) { + inString = true; + stringChar = char; + } else if (char === stringChar) { + inString = false; + stringChar = ''; + } + } + + // Count braces only when not inside strings + if (!inString) { + if (char === '{') braceCount++; + if (char === '}') { + braceCount--; + if (braceCount === 0) { + braceEnd = i; + break; + } + } + } + } + + if (braceEnd === -1) { + throw new Error('Could not find matching closing brace for object literal'); + } + + // Extract the object literal + const objectLiteral = content.substring(braceStart, braceEnd + 1); + + try { + // Parse as JSON + const parsed = JSON.parse(objectLiteral); + + if (schema) { + return schema.parse(parsed) as T; + } + + return parsed as T; + } catch (error) { + throw new Error( + `Failed to parse object literal as JSON: ${error instanceof Error ? error.message : String(error)}. ` + + 'Make sure the TypeScript/JavaScript object uses JSON-compatible syntax (no functions, comments, or trailing commas).' + ); + } + } + + getExtension(): string { + return this.format === 'typescript' ? '.ts' : '.js'; + } + + canHandle(format: MetadataFormat): boolean { + return format === 'typescript' || format === 'javascript'; + } + + getFormat(): MetadataFormat { + return this.format; + } +} diff --git a/packages/metadata/src/serializers/yaml-serializer.ts b/packages/metadata/src/serializers/yaml-serializer.ts new file mode 100644 index 000000000..128fe20c9 --- /dev/null +++ b/packages/metadata/src/serializers/yaml-serializer.ts @@ -0,0 +1,47 @@ +/** + * YAML Metadata Serializer + * + * Handles YAML format serialization and deserialization + */ + +import * as yaml from 'js-yaml'; +import type { z } from 'zod'; +import type { MetadataFormat } from '@objectstack/spec/system'; +import type { MetadataSerializer, SerializeOptions } from './serializer-interface.js'; + +export class YAMLSerializer implements MetadataSerializer { + serialize(item: T, options?: SerializeOptions): string { + const { indent = 2, sortKeys = false } = options || {}; + + return yaml.dump(item, { + indent, + sortKeys, + lineWidth: -1, // Disable line wrapping + noRefs: true, // Disable YAML references + }); + } + + deserialize(content: string, schema?: z.ZodSchema): T { + // Use JSON_SCHEMA to prevent arbitrary code execution + // This restricts YAML to JSON-compatible types only + const parsed = yaml.load(content, { schema: yaml.JSON_SCHEMA }); + + if (schema) { + return schema.parse(parsed) as T; + } + + return parsed as T; + } + + getExtension(): string { + return '.yaml'; + } + + canHandle(format: MetadataFormat): boolean { + return format === 'yaml'; + } + + getFormat(): MetadataFormat { + return 'yaml'; + } +} diff --git a/packages/metadata/tsconfig.json b/packages/metadata/tsconfig.json new file mode 100644 index 000000000..b38211580 --- /dev/null +++ b/packages/metadata/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "module": "ES2020", + "moduleResolution": "bundler" + } +} diff --git a/packages/objectql/src/protocol.ts b/packages/objectql/src/protocol.ts index 20e1b2175..facee33da 100644 --- a/packages/objectql/src/protocol.ts +++ b/packages/objectql/src/protocol.ts @@ -121,7 +121,8 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { const records = await this.engine.find(request.object, options); return { object: request.object, - records, + value: records, // OData compaibility + records, // Legacy total: records.length, hasMore: false }; diff --git a/packages/spec/json-schema/system/MetadataCollectionInfo.json b/packages/spec/json-schema/system/MetadataCollectionInfo.json new file mode 100644 index 000000000..68228e130 --- /dev/null +++ b/packages/spec/json-schema/system/MetadataCollectionInfo.json @@ -0,0 +1,53 @@ +{ + "$ref": "#/definitions/MetadataCollectionInfo", + "definitions": { + "MetadataCollectionInfo": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Collection type" + }, + "count": { + "type": "integer", + "minimum": 0, + "description": "Number of items" + }, + "formats": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "json", + "yaml", + "typescript", + "javascript" + ] + }, + "description": "Formats in collection" + }, + "totalSize": { + "type": "integer", + "minimum": 0, + "description": "Total size in bytes" + }, + "lastModified": { + "type": "string", + "format": "date-time", + "description": "Last modification date" + }, + "location": { + "type": "string", + "description": "Collection location" + } + }, + "required": [ + "type", + "count", + "formats" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/MetadataExportOptions.json b/packages/spec/json-schema/system/MetadataExportOptions.json new file mode 100644 index 000000000..b4db73416 --- /dev/null +++ b/packages/spec/json-schema/system/MetadataExportOptions.json @@ -0,0 +1,49 @@ +{ + "$ref": "#/definitions/MetadataExportOptions", + "definitions": { + "MetadataExportOptions": { + "type": "object", + "properties": { + "output": { + "type": "string", + "description": "Output file path" + }, + "format": { + "type": "string", + "enum": [ + "json", + "yaml", + "typescript", + "javascript" + ], + "default": "json", + "description": "Export format" + }, + "filter": { + "type": "string", + "description": "Filter items to export" + }, + "includeStats": { + "type": "boolean", + "default": false, + "description": "Include metadata statistics" + }, + "compress": { + "type": "boolean", + "default": false, + "description": "Compress output (gzip)" + }, + "prettify": { + "type": "boolean", + "default": true, + "description": "Pretty print output" + } + }, + "required": [ + "output" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/MetadataFormat.json b/packages/spec/json-schema/system/MetadataFormat.json new file mode 100644 index 000000000..5f515c0ec --- /dev/null +++ b/packages/spec/json-schema/system/MetadataFormat.json @@ -0,0 +1,15 @@ +{ + "$ref": "#/definitions/MetadataFormat", + "definitions": { + "MetadataFormat": { + "type": "string", + "enum": [ + "json", + "yaml", + "typescript", + "javascript" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/MetadataImportOptions.json b/packages/spec/json-schema/system/MetadataImportOptions.json new file mode 100644 index 000000000..7e9460abe --- /dev/null +++ b/packages/spec/json-schema/system/MetadataImportOptions.json @@ -0,0 +1,42 @@ +{ + "$ref": "#/definitions/MetadataImportOptions", + "definitions": { + "MetadataImportOptions": { + "type": "object", + "properties": { + "conflictResolution": { + "type": "string", + "enum": [ + "skip", + "overwrite", + "merge", + "fail" + ], + "default": "merge", + "description": "How to handle existing items" + }, + "validate": { + "type": "boolean", + "default": true, + "description": "Validate before import" + }, + "dryRun": { + "type": "boolean", + "default": false, + "description": "Simulate import without saving" + }, + "continueOnError": { + "type": "boolean", + "default": false, + "description": "Continue if validation fails" + }, + "transform": { + "type": "string", + "description": "Transform items before import" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/MetadataLoadOptions.json b/packages/spec/json-schema/system/MetadataLoadOptions.json new file mode 100644 index 000000000..3a6fa7edf --- /dev/null +++ b/packages/spec/json-schema/system/MetadataLoadOptions.json @@ -0,0 +1,52 @@ +{ + "$ref": "#/definitions/MetadataLoadOptions", + "definitions": { + "MetadataLoadOptions": { + "type": "object", + "properties": { + "patterns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "File glob patterns" + }, + "ifNoneMatch": { + "type": "string", + "description": "ETag for conditional request" + }, + "ifModifiedSince": { + "type": "string", + "format": "date-time", + "description": "Only load if modified after this date" + }, + "validate": { + "type": "boolean", + "default": true, + "description": "Validate against schema" + }, + "useCache": { + "type": "boolean", + "default": true, + "description": "Enable caching" + }, + "filter": { + "type": "string", + "description": "Filter predicate as string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "description": "Maximum items to load" + }, + "recursive": { + "type": "boolean", + "default": true, + "description": "Search subdirectories" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/MetadataLoadResult.json b/packages/spec/json-schema/system/MetadataLoadResult.json new file mode 100644 index 000000000..6d246d031 --- /dev/null +++ b/packages/spec/json-schema/system/MetadataLoadResult.json @@ -0,0 +1,86 @@ +{ + "$ref": "#/definitions/MetadataLoadResult", + "definitions": { + "MetadataLoadResult": { + "type": "object", + "properties": { + "data": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "description": "Loaded metadata" + }, + "fromCache": { + "type": "boolean", + "default": false, + "description": "Loaded from cache" + }, + "notModified": { + "type": "boolean", + "default": false, + "description": "Not modified since last request" + }, + "etag": { + "type": "string", + "description": "Entity tag" + }, + "stats": { + "type": "object", + "properties": { + "size": { + "type": "integer", + "minimum": 0, + "description": "File size in bytes" + }, + "modifiedAt": { + "type": "string", + "format": "date-time", + "description": "Last modified date" + }, + "etag": { + "type": "string", + "description": "Entity tag for cache validation" + }, + "format": { + "type": "string", + "enum": [ + "json", + "yaml", + "typescript", + "javascript" + ], + "description": "Serialization format" + }, + "path": { + "type": "string", + "description": "File system path" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata" + } + }, + "required": [ + "size", + "modifiedAt", + "etag", + "format" + ], + "additionalProperties": false, + "description": "Metadata statistics" + }, + "loadTime": { + "type": "number", + "minimum": 0, + "description": "Load duration in ms" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/MetadataLoaderContract.json b/packages/spec/json-schema/system/MetadataLoaderContract.json new file mode 100644 index 000000000..199008433 --- /dev/null +++ b/packages/spec/json-schema/system/MetadataLoaderContract.json @@ -0,0 +1,48 @@ +{ + "$ref": "#/definitions/MetadataLoaderContract", + "definitions": { + "MetadataLoaderContract": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Loader identifier" + }, + "supportedFormats": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "json", + "yaml", + "typescript", + "javascript" + ] + }, + "description": "Supported formats" + }, + "supportsWatch": { + "type": "boolean", + "default": false, + "description": "Supports file watching" + }, + "supportsWrite": { + "type": "boolean", + "default": true, + "description": "Supports write operations" + }, + "supportsCache": { + "type": "boolean", + "default": true, + "description": "Supports caching" + } + }, + "required": [ + "name", + "supportedFormats" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/MetadataManagerConfig.json b/packages/spec/json-schema/system/MetadataManagerConfig.json new file mode 100644 index 000000000..6dbbf076e --- /dev/null +++ b/packages/spec/json-schema/system/MetadataManagerConfig.json @@ -0,0 +1,108 @@ +{ + "$ref": "#/definitions/MetadataManagerConfig", + "definitions": { + "MetadataManagerConfig": { + "type": "object", + "properties": { + "rootDir": { + "type": "string", + "description": "Root directory path" + }, + "formats": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "json", + "yaml", + "typescript", + "javascript" + ] + }, + "default": [ + "typescript", + "json", + "yaml" + ], + "description": "Enabled formats" + }, + "cache": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true, + "description": "Enable caching" + }, + "ttl": { + "type": "integer", + "minimum": 0, + "default": 3600, + "description": "Cache TTL in seconds" + }, + "maxSize": { + "type": "integer", + "minimum": 0, + "description": "Max cache size in bytes" + } + }, + "additionalProperties": false, + "description": "Cache settings" + }, + "watch": { + "type": "boolean", + "default": false, + "description": "Enable file watching" + }, + "watchOptions": { + "type": "object", + "properties": { + "ignored": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Patterns to ignore" + }, + "persistent": { + "type": "boolean", + "default": true, + "description": "Keep process running" + }, + "ignoreInitial": { + "type": "boolean", + "default": true, + "description": "Ignore initial add events" + } + }, + "additionalProperties": false, + "description": "File watcher options" + }, + "validation": { + "type": "object", + "properties": { + "strict": { + "type": "boolean", + "default": true, + "description": "Strict validation" + }, + "throwOnError": { + "type": "boolean", + "default": true, + "description": "Throw on validation error" + } + }, + "additionalProperties": false, + "description": "Validation settings" + }, + "loaderOptions": { + "type": "object", + "additionalProperties": {}, + "description": "Loader-specific configuration" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/MetadataSaveOptions.json b/packages/spec/json-schema/system/MetadataSaveOptions.json new file mode 100644 index 000000000..c749961eb --- /dev/null +++ b/packages/spec/json-schema/system/MetadataSaveOptions.json @@ -0,0 +1,64 @@ +{ + "$ref": "#/definitions/MetadataSaveOptions", + "definitions": { + "MetadataSaveOptions": { + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": [ + "json", + "yaml", + "typescript", + "javascript" + ], + "default": "typescript", + "description": "Output format" + }, + "prettify": { + "type": "boolean", + "default": true, + "description": "Format with indentation" + }, + "indent": { + "type": "integer", + "minimum": 0, + "maximum": 8, + "default": 2, + "description": "Indentation spaces" + }, + "sortKeys": { + "type": "boolean", + "default": false, + "description": "Sort object keys" + }, + "includeDefaults": { + "type": "boolean", + "default": false, + "description": "Include default values" + }, + "backup": { + "type": "boolean", + "default": false, + "description": "Create backup file" + }, + "overwrite": { + "type": "boolean", + "default": true, + "description": "Overwrite existing file" + }, + "atomic": { + "type": "boolean", + "default": true, + "description": "Use atomic write operation" + }, + "path": { + "type": "string", + "description": "Custom output path" + } + }, + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/MetadataSaveResult.json b/packages/spec/json-schema/system/MetadataSaveResult.json new file mode 100644 index 000000000..ef378699f --- /dev/null +++ b/packages/spec/json-schema/system/MetadataSaveResult.json @@ -0,0 +1,42 @@ +{ + "$ref": "#/definitions/MetadataSaveResult", + "definitions": { + "MetadataSaveResult": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "description": "Save successful" + }, + "path": { + "type": "string", + "description": "Output path" + }, + "etag": { + "type": "string", + "description": "Generated entity tag" + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "File size" + }, + "saveTime": { + "type": "number", + "minimum": 0, + "description": "Save duration in ms" + }, + "backupPath": { + "type": "string", + "description": "Backup file path" + } + }, + "required": [ + "success", + "path" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/MetadataStats.json b/packages/spec/json-schema/system/MetadataStats.json new file mode 100644 index 000000000..eea204916 --- /dev/null +++ b/packages/spec/json-schema/system/MetadataStats.json @@ -0,0 +1,51 @@ +{ + "$ref": "#/definitions/MetadataStats", + "definitions": { + "MetadataStats": { + "type": "object", + "properties": { + "size": { + "type": "integer", + "minimum": 0, + "description": "File size in bytes" + }, + "modifiedAt": { + "type": "string", + "format": "date-time", + "description": "Last modified date" + }, + "etag": { + "type": "string", + "description": "Entity tag for cache validation" + }, + "format": { + "type": "string", + "enum": [ + "json", + "yaml", + "typescript", + "javascript" + ], + "description": "Serialization format" + }, + "path": { + "type": "string", + "description": "File system path" + }, + "metadata": { + "type": "object", + "additionalProperties": {}, + "description": "Provider-specific metadata" + } + }, + "required": [ + "size", + "modifiedAt", + "etag", + "format" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/json-schema/system/MetadataWatchEvent.json b/packages/spec/json-schema/system/MetadataWatchEvent.json new file mode 100644 index 000000000..25b4e5b0d --- /dev/null +++ b/packages/spec/json-schema/system/MetadataWatchEvent.json @@ -0,0 +1,48 @@ +{ + "$ref": "#/definitions/MetadataWatchEvent", + "definitions": { + "MetadataWatchEvent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "added", + "changed", + "deleted" + ], + "description": "Event type" + }, + "metadataType": { + "type": "string", + "description": "Type of metadata" + }, + "name": { + "type": "string", + "description": "Item identifier" + }, + "path": { + "type": "string", + "description": "File path" + }, + "data": { + "description": "Item data" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "Event timestamp" + } + }, + "required": [ + "type", + "metadataType", + "name", + "path", + "timestamp" + ], + "additionalProperties": false + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts index 9252e7682..2f16c9c78 100644 --- a/packages/spec/src/system/index.ts +++ b/packages/spec/src/system/index.ts @@ -43,6 +43,9 @@ export * from './cache.zod'; // Message Queue Protocol export * from './message-queue.zod'; +// Metadata Loader Protocol +export * from './metadata-loader.zod'; + // Search Engine Protocol export * from './search-engine.zod'; diff --git a/packages/spec/src/system/metadata-loader.test.ts b/packages/spec/src/system/metadata-loader.test.ts new file mode 100644 index 000000000..bdacb0cb7 --- /dev/null +++ b/packages/spec/src/system/metadata-loader.test.ts @@ -0,0 +1,408 @@ +import { describe, it, expect } from 'vitest'; +import { + MetadataFormatSchema, + MetadataStatsSchema, + MetadataLoadOptionsSchema, + MetadataSaveOptionsSchema, + MetadataExportOptionsSchema, + MetadataImportOptionsSchema, + MetadataLoadResultSchema, + MetadataSaveResultSchema, + MetadataWatchEventSchema, + MetadataCollectionInfoSchema, + MetadataLoaderContractSchema, + MetadataManagerConfigSchema, +} from './metadata-loader.zod'; + +describe('MetadataLoaderProtocol', () => { + describe('MetadataFormatSchema', () => { + it('should accept valid formats', () => { + expect(MetadataFormatSchema.parse('json')).toBe('json'); + expect(MetadataFormatSchema.parse('yaml')).toBe('yaml'); + expect(MetadataFormatSchema.parse('typescript')).toBe('typescript'); + expect(MetadataFormatSchema.parse('javascript')).toBe('javascript'); + }); + + it('should reject invalid formats', () => { + expect(() => MetadataFormatSchema.parse('xml')).toThrow(); + expect(() => MetadataFormatSchema.parse('toml')).toThrow(); + }); + }); + + describe('MetadataStatsSchema', () => { + it('should validate metadata statistics', () => { + const stats = { + size: 1024, + modifiedAt: new Date('2026-01-31T00:00:00Z'), + etag: '"abc123"', + format: 'json' as const, + }; + + const result = MetadataStatsSchema.parse(stats); + expect(result.size).toBe(1024); + expect(result.etag).toBe('"abc123"'); + expect(result.format).toBe('json'); + }); + + it('should allow optional fields', () => { + const stats = { + size: 2048, + modifiedAt: new Date(), + etag: '"xyz789"', + format: 'yaml' as const, + path: '/metadata/objects/customer.object.yaml', + metadata: { encoding: 'utf-8' }, + }; + + const result = MetadataStatsSchema.parse(stats); + expect(result.path).toBe('/metadata/objects/customer.object.yaml'); + expect(result.metadata).toEqual({ encoding: 'utf-8' }); + }); + + it('should reject negative size', () => { + const stats = { + size: -100, + modifiedAt: new Date(), + etag: '"abc"', + format: 'json' as const, + }; + + expect(() => MetadataStatsSchema.parse(stats)).toThrow(); + }); + }); + + describe('MetadataLoadOptionsSchema', () => { + it('should apply default values', () => { + const options = {}; + const result = MetadataLoadOptionsSchema.parse(options); + + expect(result.validate).toBe(true); + expect(result.useCache).toBe(true); + expect(result.recursive).toBe(true); + }); + + it('should accept all options', () => { + const options = { + patterns: ['**/*.object.ts', '**/*.object.json'], + ifNoneMatch: '"etag123"', + ifModifiedSince: new Date('2026-01-01T00:00:00Z'), + validate: false, + useCache: false, + filter: '(item) => item.name.startsWith("sys_")', + limit: 100, + recursive: false, + }; + + const result = MetadataLoadOptionsSchema.parse(options); + expect(result.patterns).toHaveLength(2); + expect(result.limit).toBe(100); + expect(result.validate).toBe(false); + }); + }); + + describe('MetadataSaveOptionsSchema', () => { + it('should apply default values', () => { + const options = {}; + const result = MetadataSaveOptionsSchema.parse(options); + + expect(result.format).toBe('typescript'); + expect(result.prettify).toBe(true); + expect(result.indent).toBe(2); + expect(result.overwrite).toBe(true); + expect(result.atomic).toBe(true); + }); + + it('should validate indent range', () => { + expect(() => + MetadataSaveOptionsSchema.parse({ indent: -1 }) + ).toThrow(); + + expect(() => + MetadataSaveOptionsSchema.parse({ indent: 10 }) + ).toThrow(); + + expect( + MetadataSaveOptionsSchema.parse({ indent: 4 }).indent + ).toBe(4); + }); + + it('should accept custom path', () => { + const options = { + path: '/custom/path/object.ts', + format: 'json' as const, + }; + + const result = MetadataSaveOptionsSchema.parse(options); + expect(result.path).toBe('/custom/path/object.ts'); + expect(result.format).toBe('json'); + }); + }); + + describe('MetadataExportOptionsSchema', () => { + it('should require output path', () => { + expect(() => MetadataExportOptionsSchema.parse({})).toThrow(); + + const options = { output: './export/objects.json' }; + const result = MetadataExportOptionsSchema.parse(options); + expect(result.output).toBe('./export/objects.json'); + }); + + it('should apply defaults', () => { + const options = { output: './export.json' }; + const result = MetadataExportOptionsSchema.parse(options); + + expect(result.format).toBe('json'); + expect(result.includeStats).toBe(false); + expect(result.compress).toBe(false); + expect(result.prettify).toBe(true); + }); + }); + + describe('MetadataImportOptionsSchema', () => { + it('should apply default conflict resolution', () => { + const options = {}; + const result = MetadataImportOptionsSchema.parse(options); + + expect(result.conflictResolution).toBe('merge'); + expect(result.validate).toBe(true); + expect(result.dryRun).toBe(false); + expect(result.continueOnError).toBe(false); + }); + + it('should accept all conflict strategies', () => { + const strategies = ['skip', 'overwrite', 'merge', 'fail'] as const; + + strategies.forEach(strategy => { + const result = MetadataImportOptionsSchema.parse({ + conflictResolution: strategy + }); + expect(result.conflictResolution).toBe(strategy); + }); + }); + + it('should accept transform function', () => { + const options = { + transform: '(item) => ({ ...item, imported: true })', + }; + + const result = MetadataImportOptionsSchema.parse(options); + expect(result.transform).toBeDefined(); + }); + }); + + describe('MetadataLoadResultSchema', () => { + it('should validate load result', () => { + const result = { + data: { name: 'customer', label: 'Customer' }, + fromCache: false, + notModified: false, + }; + + const validated = MetadataLoadResultSchema.parse(result); + expect(validated.data).toBeDefined(); + expect(validated.fromCache).toBe(false); + }); + + it('should accept null data (not found)', () => { + const result = { + data: null, + fromCache: false, + notModified: false, + }; + + const validated = MetadataLoadResultSchema.parse(result); + expect(validated.data).toBeNull(); + }); + + it('should include optional fields', () => { + const result = { + data: { name: 'test' }, + fromCache: true, + notModified: true, + etag: '"abc123"', + stats: { + size: 512, + modifiedAt: new Date(), + etag: '"abc123"', + format: 'typescript' as const, + }, + loadTime: 45.5, + }; + + const validated = MetadataLoadResultSchema.parse(result); + expect(validated.etag).toBe('"abc123"'); + expect(validated.loadTime).toBe(45.5); + expect(validated.stats).toBeDefined(); + }); + }); + + describe('MetadataSaveResultSchema', () => { + it('should validate save result', () => { + const result = { + success: true, + path: '/metadata/objects/customer.object.ts', + }; + + const validated = MetadataSaveResultSchema.parse(result); + expect(validated.success).toBe(true); + expect(validated.path).toBeDefined(); + }); + + it('should include optional fields', () => { + const result = { + success: true, + path: '/metadata/objects/customer.object.ts', + etag: '"new-etag"', + size: 2048, + saveTime: 12.3, + backupPath: '/metadata/objects/customer.object.ts.bak', + }; + + const validated = MetadataSaveResultSchema.parse(result); + expect(validated.size).toBe(2048); + expect(validated.backupPath).toBeDefined(); + }); + }); + + describe('MetadataWatchEventSchema', () => { + it('should validate watch events', () => { + const events = [ + { + type: 'added' as const, + metadataType: 'object', + name: 'customer', + path: '/objects/customer.object.ts', + data: { name: 'customer' }, + timestamp: new Date(), + }, + { + type: 'changed' as const, + metadataType: 'view', + name: 'customer_list', + path: '/views/customer_list.view.ts', + timestamp: new Date(), + }, + { + type: 'deleted' as const, + metadataType: 'app', + name: 'old_app', + path: '/apps/old_app.ts', + timestamp: new Date(), + }, + ]; + + events.forEach(event => { + const validated = MetadataWatchEventSchema.parse(event); + expect(validated.type).toBe(event.type); + expect(validated.metadataType).toBeDefined(); + }); + }); + }); + + describe('MetadataCollectionInfoSchema', () => { + it('should validate collection info', () => { + const info = { + type: 'object', + count: 42, + formats: ['typescript', 'json'] as const, + }; + + const validated = MetadataCollectionInfoSchema.parse(info); + expect(validated.count).toBe(42); + expect(validated.formats).toHaveLength(2); + }); + + it('should accept optional fields', () => { + const info = { + type: 'view', + count: 15, + formats: ['yaml'] as const, + totalSize: 51200, + lastModified: new Date('2026-01-31T00:00:00Z'), + location: '/metadata/views', + }; + + const validated = MetadataCollectionInfoSchema.parse(info); + expect(validated.totalSize).toBe(51200); + expect(validated.location).toBe('/metadata/views'); + }); + }); + + describe('MetadataLoaderContractSchema', () => { + it('should validate loader contract', () => { + const contract = { + name: 'filesystem', + supportedFormats: ['json', 'yaml', 'typescript'] as const, + }; + + const validated = MetadataLoaderContractSchema.parse(contract); + expect(validated.name).toBe('filesystem'); + expect(validated.supportsWatch).toBe(false); // default + expect(validated.supportsWrite).toBe(true); // default + expect(validated.supportsCache).toBe(true); // default + }); + + it('should allow custom capabilities', () => { + const contract = { + name: 'http', + supportedFormats: ['json'] as const, + supportsWatch: false, + supportsWrite: false, + supportsCache: true, + }; + + const validated = MetadataLoaderContractSchema.parse(contract); + expect(validated.supportsWrite).toBe(false); + expect(validated.supportsCache).toBe(true); + }); + }); + + describe('MetadataManagerConfigSchema', () => { + it('should apply defaults', () => { + const config = {}; + const validated = MetadataManagerConfigSchema.parse(config); + + expect(validated.formats).toEqual(['typescript', 'json', 'yaml']); + expect(validated.watch).toBe(false); + }); + + it('should validate complete configuration', () => { + const config = { + rootDir: '/metadata', + formats: ['typescript', 'json'] as const, + cache: { + enabled: true, + ttl: 7200, + maxSize: 10485760, // 10MB + }, + watch: true, + watchOptions: { + ignored: ['**/node_modules/**', '**/*.test.ts'], + persistent: true, + ignoreInitial: true, + }, + validation: { + strict: true, + throwOnError: true, + }, + loaderOptions: { + encoding: 'utf-8', + }, + }; + + const validated = MetadataManagerConfigSchema.parse(config); + expect(validated.rootDir).toBe('/metadata'); + expect(validated.cache?.ttl).toBe(7200); + expect(validated.watchOptions?.ignored).toHaveLength(2); + expect(validated.loaderOptions?.encoding).toBe('utf-8'); + }); + + it('should reject negative TTL', () => { + const config = { + cache: { enabled: true, ttl: -100 }, + }; + + expect(() => MetadataManagerConfigSchema.parse(config)).toThrow(); + }); + }); +}); diff --git a/packages/spec/src/system/metadata-loader.zod.ts b/packages/spec/src/system/metadata-loader.zod.ts new file mode 100644 index 000000000..47a951814 --- /dev/null +++ b/packages/spec/src/system/metadata-loader.zod.ts @@ -0,0 +1,456 @@ +import { z } from 'zod'; + +/** + * # Metadata Loader Protocol + * + * Defines the standard interface for loading and saving metadata in ObjectStack. + * This protocol enables consistent metadata operations across different storage backends + * (filesystem, HTTP, S3, databases) and serialization formats (JSON, YAML, TypeScript). + */ + +/** + * Metadata Format Enum + * Supported serialization formats for metadata + */ +export const MetadataFormatSchema = z.enum(['json', 'yaml', 'typescript', 'javascript']); + +/** + * Metadata Statistics + * Information about a metadata item without loading its full content + */ +export const MetadataStatsSchema = z.object({ + /** + * Size of the metadata file in bytes + */ + size: z.number().int().min(0).describe('File size in bytes'), + + /** + * Last modification timestamp + */ + modifiedAt: z.date().describe('Last modified date'), + + /** + * ETag for cache validation + * Used for conditional requests (If-None-Match header) + */ + etag: z.string().describe('Entity tag for cache validation'), + + /** + * Serialization format + */ + format: MetadataFormatSchema.describe('Serialization format'), + + /** + * Full file path (if applicable) + */ + path: z.string().optional().describe('File system path'), + + /** + * Additional metadata provider-specific properties + */ + metadata: z.record(z.any()).optional().describe('Provider-specific metadata'), +}); + +/** + * Metadata Load Options + */ +export const MetadataLoadOptionsSchema = z.object({ + /** + * Glob patterns to match files + * Example: ["**\/*.object.ts", "**\/*.object.json"] + */ + patterns: z.array(z.string()).optional().describe('File glob patterns'), + + /** + * If-None-Match header for conditional loading + * Only load if ETag doesn't match + */ + ifNoneMatch: z.string().optional().describe('ETag for conditional request'), + + /** + * If-Modified-Since header for conditional loading + */ + ifModifiedSince: z.date().optional().describe('Only load if modified after this date'), + + /** + * Whether to validate against Zod schema + */ + validate: z.boolean().default(true).describe('Validate against schema'), + + /** + * Whether to use cache if available + */ + useCache: z.boolean().default(true).describe('Enable caching'), + + /** + * Filter function (serialized as string) + * Example: "(item) => item.name.startsWith('sys_')" + */ + filter: z.string().optional().describe('Filter predicate as string'), + + /** + * Maximum number of items to load + */ + limit: z.number().int().min(1).optional().describe('Maximum items to load'), + + /** + * Recursively search subdirectories + */ + recursive: z.boolean().default(true).describe('Search subdirectories'), +}); + +/** + * Metadata Save Options + */ +export const MetadataSaveOptionsSchema = z.object({ + /** + * Serialization format + */ + format: MetadataFormatSchema.default('typescript').describe('Output format'), + + /** + * Prettify output (formatted with indentation) + */ + prettify: z.boolean().default(true).describe('Format with indentation'), + + /** + * Indentation size (spaces) + */ + indent: z.number().int().min(0).max(8).default(2).describe('Indentation spaces'), + + /** + * Sort object keys alphabetically + */ + sortKeys: z.boolean().default(false).describe('Sort object keys'), + + /** + * Include default values in output + */ + includeDefaults: z.boolean().default(false).describe('Include default values'), + + /** + * Create backup before overwriting + */ + backup: z.boolean().default(false).describe('Create backup file'), + + /** + * Overwrite if exists + */ + overwrite: z.boolean().default(true).describe('Overwrite existing file'), + + /** + * Atomic write (write to temp file, then rename) + */ + atomic: z.boolean().default(true).describe('Use atomic write operation'), + + /** + * Custom file path (overrides default location) + */ + path: z.string().optional().describe('Custom output path'), +}); + +/** + * Metadata Export Options + */ +export const MetadataExportOptionsSchema = z.object({ + /** + * Output file path + */ + output: z.string().describe('Output file path'), + + /** + * Export format + */ + format: MetadataFormatSchema.default('json').describe('Export format'), + + /** + * Filter predicate as string + */ + filter: z.string().optional().describe('Filter items to export'), + + /** + * Include statistics in export + */ + includeStats: z.boolean().default(false).describe('Include metadata statistics'), + + /** + * Compress output + */ + compress: z.boolean().default(false).describe('Compress output (gzip)'), + + /** + * Pretty print output + */ + prettify: z.boolean().default(true).describe('Pretty print output'), +}); + +/** + * Metadata Import Options + */ +export const MetadataImportOptionsSchema = z.object({ + /** + * Conflict resolution strategy + */ + conflictResolution: z.enum(['skip', 'overwrite', 'merge', 'fail']) + .default('merge') + .describe('How to handle existing items'), + + /** + * Validate items against schema + */ + validate: z.boolean().default(true).describe('Validate before import'), + + /** + * Dry run (don't actually save) + */ + dryRun: z.boolean().default(false).describe('Simulate import without saving'), + + /** + * Continue on errors + */ + continueOnError: z.boolean().default(false).describe('Continue if validation fails'), + + /** + * Transform function (as string) + * Example: "(item) => ({ ...item, imported: true })" + */ + transform: z.string().optional().describe('Transform items before import'), +}); + +/** + * Metadata Loader Result + * Result of a metadata load operation + */ +export const MetadataLoadResultSchema = z.object({ + /** + * Loaded data + */ + data: z.any().nullable().describe('Loaded metadata'), + + /** + * Whether data came from cache (304 Not Modified) + */ + fromCache: z.boolean().default(false).describe('Loaded from cache'), + + /** + * Not modified (conditional request matched) + */ + notModified: z.boolean().default(false).describe('Not modified since last request'), + + /** + * ETag of loaded data + */ + etag: z.string().optional().describe('Entity tag'), + + /** + * Statistics about loaded data + */ + stats: MetadataStatsSchema.optional().describe('Metadata statistics'), + + /** + * Load time in milliseconds + */ + loadTime: z.number().min(0).optional().describe('Load duration in ms'), +}); + +/** + * Metadata Save Result + */ +export const MetadataSaveResultSchema = z.object({ + /** + * Whether save was successful + */ + success: z.boolean().describe('Save successful'), + + /** + * Path where file was saved + */ + path: z.string().describe('Output path'), + + /** + * Generated ETag + */ + etag: z.string().optional().describe('Generated entity tag'), + + /** + * File size in bytes + */ + size: z.number().int().min(0).optional().describe('File size'), + + /** + * Save time in milliseconds + */ + saveTime: z.number().min(0).optional().describe('Save duration in ms'), + + /** + * Backup path (if created) + */ + backupPath: z.string().optional().describe('Backup file path'), +}); + +/** + * Metadata Watch Event + */ +export const MetadataWatchEventSchema = z.object({ + /** + * Event type + */ + type: z.enum(['added', 'changed', 'deleted']).describe('Event type'), + + /** + * Metadata type (e.g., 'object', 'view', 'app') + */ + metadataType: z.string().describe('Type of metadata'), + + /** + * Item name/identifier + */ + name: z.string().describe('Item identifier'), + + /** + * Full file path + */ + path: z.string().describe('File path'), + + /** + * Loaded item data (for added/changed events) + */ + data: z.any().optional().describe('Item data'), + + /** + * Timestamp + */ + timestamp: z.date().describe('Event timestamp'), +}); + +/** + * Metadata Collection Info + * Summary of a metadata collection + */ +export const MetadataCollectionInfoSchema = z.object({ + /** + * Collection type (e.g., 'object', 'view', 'app') + */ + type: z.string().describe('Collection type'), + + /** + * Total items in collection + */ + count: z.number().int().min(0).describe('Number of items'), + + /** + * Formats found in collection + */ + formats: z.array(MetadataFormatSchema).describe('Formats in collection'), + + /** + * Total size in bytes + */ + totalSize: z.number().int().min(0).optional().describe('Total size in bytes'), + + /** + * Last modified timestamp + */ + lastModified: z.date().optional().describe('Last modification date'), + + /** + * Collection location (path or URL) + */ + location: z.string().optional().describe('Collection location'), +}); + +/** + * Metadata Loader Interface Contract + * Defines the standard methods all metadata loaders must implement + */ +export const MetadataLoaderContractSchema = z.object({ + /** + * Loader name/identifier + */ + name: z.string().describe('Loader identifier'), + + /** + * Supported formats + */ + supportedFormats: z.array(MetadataFormatSchema).describe('Supported formats'), + + /** + * Whether loader supports watching for changes + */ + supportsWatch: z.boolean().default(false).describe('Supports file watching'), + + /** + * Whether loader supports saving + */ + supportsWrite: z.boolean().default(true).describe('Supports write operations'), + + /** + * Whether loader supports caching + */ + supportsCache: z.boolean().default(true).describe('Supports caching'), +}); + +/** + * Metadata Manager Configuration + */ +export const MetadataManagerConfigSchema = z.object({ + /** + * Root directory for metadata (for filesystem loaders) + */ + rootDir: z.string().optional().describe('Root directory path'), + + /** + * Enabled serialization formats + */ + formats: z.array(MetadataFormatSchema).default(['typescript', 'json', 'yaml']).describe('Enabled formats'), + + /** + * Cache configuration + */ + cache: z.object({ + enabled: z.boolean().default(true).describe('Enable caching'), + ttl: z.number().int().min(0).default(3600).describe('Cache TTL in seconds'), + maxSize: z.number().int().min(0).optional().describe('Max cache size in bytes'), + }).optional().describe('Cache settings'), + + /** + * Watch for file changes + */ + watch: z.boolean().default(false).describe('Enable file watching'), + + /** + * Watch options + */ + watchOptions: z.object({ + ignored: z.array(z.string()).optional().describe('Patterns to ignore'), + persistent: z.boolean().default(true).describe('Keep process running'), + ignoreInitial: z.boolean().default(true).describe('Ignore initial add events'), + }).optional().describe('File watcher options'), + + /** + * Validation settings + */ + validation: z.object({ + strict: z.boolean().default(true).describe('Strict validation'), + throwOnError: z.boolean().default(true).describe('Throw on validation error'), + }).optional().describe('Validation settings'), + + /** + * Loader-specific options + */ + loaderOptions: z.record(z.any()).optional().describe('Loader-specific configuration'), +}); + +// Export types +export type MetadataFormat = z.infer; +export type MetadataStats = z.infer; +export type MetadataLoadOptions = z.input; +export type MetadataSaveOptions = z.infer; +export type MetadataExportOptions = z.infer; +export type MetadataImportOptions = z.infer; +export type MetadataLoadResult = z.infer; +export type MetadataSaveResult = z.infer; +export type MetadataWatchEvent = z.infer; +export type MetadataCollectionInfo = z.infer; +export type MetadataLoaderContract = z.infer; +export type MetadataManagerConfig = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 143c68376..e5d018e5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -395,6 +395,43 @@ importers: specifier: ^1.0.0 version: 1.6.1(@types/node@20.19.30)(lightningcss@1.30.2) + packages/metadata: + dependencies: + '@objectstack/core': + specifier: workspace:* + version: link:../core + '@objectstack/spec': + specifier: workspace:* + version: link:../spec + '@objectstack/types': + specifier: workspace:* + version: link:../types + chokidar: + specifier: ^3.5.3 + version: 3.6.0 + glob: + specifier: ^10.3.10 + version: 10.5.0 + js-yaml: + specifier: ^4.1.0 + version: 4.1.1 + zod: + specifier: ^3.22.4 + version: 3.25.76 + devDependencies: + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/node': + specifier: ^20.0.0 + version: 20.19.30 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vitest: + specifier: ^1.0.0 + version: 1.6.1(@types/node@20.19.30)(lightningcss@1.30.2) + packages/objectql: dependencies: '@objectstack/core': @@ -1991,6 +2028,9 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -2144,6 +2184,10 @@ packages: any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -2200,6 +2244,10 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} @@ -2265,6 +2313,10 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -2757,6 +2809,10 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} @@ -3236,6 +3292,10 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + npm-run-path@5.3.0: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3516,6 +3576,10 @@ packages: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -5491,6 +5555,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/js-yaml@4.0.9': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -5664,6 +5730,11 @@ snapshots: any-promise@1.3.0: {} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + arg@4.1.3: {} argparse@1.0.10: @@ -5707,6 +5778,8 @@ snapshots: dependencies: is-windows: 1.0.2 + binary-extensions@2.3.0: {} + brace-expansion@2.0.2: dependencies: balanced-match: 1.0.2 @@ -5775,6 +5848,18 @@ snapshots: check-error@2.1.3: {} + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -6318,6 +6403,10 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + is-decimal@2.0.1: {} is-extglob@2.1.1: {} @@ -7031,6 +7120,8 @@ snapshots: node-releases@2.0.27: {} + normalize-path@3.0.0: {} + npm-run-path@5.3.0: dependencies: path-key: 4.0.0 @@ -7301,6 +7392,10 @@ snapshots: process: 0.11.10 string_decoder: 1.3.0 + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + readdirp@4.1.2: {} readdirp@5.0.0: {} diff --git "a/\345\205\203\346\225\260\346\215\256\347\256\241\347\220\206\346\236\266\346\236\204\350\257\204\344\274\260\346\212\245\345\221\212.md" "b/\345\205\203\346\225\260\346\215\256\347\256\241\347\220\206\346\236\266\346\236\204\350\257\204\344\274\260\346\212\245\345\221\212.md" new file mode 100644 index 000000000..8e34af382 --- /dev/null +++ "b/\345\205\203\346\225\260\346\215\256\347\256\241\347\220\206\346\236\266\346\236\204\350\257\204\344\274\260\346\212\245\345\221\212.md" @@ -0,0 +1,504 @@ +# ObjectStack 元数据加载/保存功能包评估报告 + +**日期**: 2026年1月31日 +**评估对象**: 元数据加载/保存功能应该放在哪个软件包中 +**评估方法**: 全面扫描系统现有软件包、协议和微内核架构 + +--- + +## 一、执行摘要 (Executive Summary) + +经过对ObjectStack微内核架构的全面分析,**建议创建新的独立包 `@objectstack/metadata`(Layer 3)**,专门负责元数据的加载、保存、序列化和持久化操作。 + +这个决策基于以下关键因素: +- ✅ 符合微内核架构的关注点分离原则 +- ✅ 避免违反现有包的单一职责原则 +- ✅ 为未来功能扩展提供清晰的基础 +- ✅ 保持向后兼容性,支持渐进式迁移 + +--- + +## 二、现状分析 (Current State Analysis) + +### 2.1 现有软件包结构 + +ObjectStack采用分层架构,共6层: + +| 层级 | 包名 | 职责 | +|-----|------|-----| +| Layer 0 | @objectstack/spec | 协议定义(Zod schemas) | +| Layer 1 | @objectstack/types | 共享类型定义 | +| Layer 2 | @objectstack/core | 微内核(插件生命周期、服务注册) | +| Layer 3 | @objectstack/objectql | 查询引擎(内存注册表) | +| Layer 3 | @objectstack/runtime | 运行时工具(插件包装器) | +| Layer 4 | @objectstack/client | 客户端SDK | +| Layer 4 | @objectstack/client-react | React集成 | +| Layer 5 | @objectstack/driver-* | 驱动插件 | +| Layer 6 | @objectstack/cli | 命令行工具 | + +### 2.2 现有元数据相关组件 + +当前元数据功能分散在多个包中: + +1. **SchemaRegistry** (`@objectstack/objectql`) + - 位置:`packages/objectql/src/registry.ts` + - 功能:内存中的元数据注册表 + - 限制:仅支持运行时存储,无持久化能力 + +2. **AppPlugin/DriverPlugin** (`@objectstack/runtime`) + - 位置:`packages/runtime/src/app-plugin.ts` + - 功能:加载应用清单并注册为服务 + - 限制:仅处理清单加载,无通用化 + +3. **Metadata Hooks** (`@objectstack/client-react`) + - 位置:`packages/client-react/src/metadata-hooks.tsx` + - 功能:客户端获取元数据(useObject, useView) + - 限制:仅客户端,依赖HTTP API + +4. **Hub/Composer协议** (`@objectstack/spec/hub`) + - 位置:`packages/spec/src/hub/composer.zod.ts` + - 功能:云端清单编排和组合 + - 限制:仅协议定义,无实现 + +### 2.3 存在的问题 + +1. **职责分散**:元数据I/O逻辑散落在多个包中 +2. **缺乏统一接口**:没有标准的加载/保存API +3. **重复代码**:序列化逻辑可能在多处重复 +4. **扩展困难**:添加新格式(YAML, TypeScript)需要改多处 +5. **缺乏持久化**:SchemaRegistry只在内存中,无法保存到磁盘 + +--- + +## 三、候选方案评估 (Options Evaluation) + +### 方案A:放入 @objectstack/spec ❌ + +**理由拒绝:** +- Spec包的核心约束是"零运行时依赖(除Zod外)" +- 添加I/O功能需要引入 `fs`, `glob`, `js-yaml` 等依赖 +- 违反了"协议定义与实现分离"的原则 +- Spec应该定义"是什么",而不是"怎么做" + +**影响:** +- 破坏包的纯净性 +- 增加不必要的依赖 +- 混淆协议层和实现层 + +### 方案B:放入 @objectstack/core ❌ + +**理由拒绝:** +- Core是最小化微内核,只负责插件生命周期和服务注册 +- 添加元数据I/O是特定领域逻辑,会膨胀内核 +- 违反"微内核应该保持最小"的设计原则 +- 内核应该对领域逻辑保持无知 + +**影响:** +- 违背微内核架构设计 +- 增加内核复杂度 +- 降低内核稳定性 + +### 方案C:放入 @objectstack/objectql ❌ + +**理由拒绝:** +- ObjectQL专注于查询执行和驱动管理 +- 当前的SchemaRegistry只是内存存储 +- 添加持久化会违反"单一职责原则" +- ObjectQL层不应该知道文件系统和序列化格式 + +**影响:** +- 混淆查询引擎和元数据管理的职责 +- ObjectQL变得臃肿 +- 难以独立测试和维护 + +### 方案D:放入 @objectstack/runtime ❌ + +**理由拒绝:** +- Runtime已经是一个"万金油"包,职责不够清晰 +- 添加更多功能会使边界更加模糊 +- Runtime应该专注于插件包装和HTTP工具 +- 混合I/O逻辑会导致包失去焦点 + +**影响:** +- 进一步模糊包的职责 +- 增加维护复杂度 +- 不符合清晰架构原则 + +### 方案E:放入 @objectstack/cli ❌ + +**理由拒绝:** +- CLI是Layer 6的独立工具 +- 服务器运行时不应该依赖CLI工具 +- 创建了不合理的向上依赖 +- CLI应该使用底层库,而不是被底层使用 + +**影响:** +- 破坏依赖层次 +- 无法在服务器端使用 +- 违反依赖倒置原则 + +--- + +## 四、推荐方案 (Recommended Solution) + +### ✅ 方案F:创建新包 @objectstack/metadata (Layer 3) + +#### 4.1 核心优势 + +1. **清晰的职责边界** + - 专注于元数据的序列化、反序列化和持久化 + - 不涉及查询执行(ObjectQL的职责) + - 不涉及插件生命周期(Core的职责) + +2. **符合微内核原则** + - 作为可选的第三方包,不影响内核 + - 通过服务注册进行集成 + - 可以被其他包依赖和使用 + +3. **易于扩展** + - 添加新的序列化格式(XML, TOML)只需实现接口 + - 添加新的加载器(HTTP, S3, Database)只需添加插件 + - 支持自定义缓存策略 + +4. **向后兼容** + - 不破坏现有代码 + - SchemaRegistry可以继续使用 + - 提供桥接方案实现双向同步 + +#### 4.2 包的核心职责 + +```typescript +// 1. 元数据序列化/反序列化 +interface MetadataSerializer { + serialize(item: T, options?: SerializeOptions): string; + deserialize(content: string, schema?: ZodSchema): T; +} + +// 2. 文件系统操作 +interface MetadataLoader { + load(type: string, name: string): Promise; + save(type: string, name: string, data: T): Promise; + watch(type: string, callback: WatchCallback): void; +} + +// 3. 缓存管理 +interface MetadataCache { + get(key: string): Promise; + set(key: string, value: T, ttl?: number): Promise; + invalidate(key: string): Promise; +} + +// 4. 导入/导出 +interface MetadataExporter { + exportCollection(type: string, options: ExportOptions): Promise; + importCollection(path: string, options: ImportOptions): Promise; +} + +// 5. 验证 +interface MetadataValidator { + validate(data: T, schema: ZodSchema): ValidationResult; + migrate(data: any, fromVersion: string, toVersion: string): any; +} +``` + +#### 4.3 支持的功能 + +| 功能类别 | 具体功能 | +|---------|---------| +| **格式支持** | JSON, YAML, TypeScript, JavaScript | +| **加载策略** | 文件系统, HTTP, S3, 数据库(扩展) | +| **缓存策略** | ETag, 内存缓存, Redis(扩展) | +| **文件监听** | 开发模式热重载 | +| **验证** | Zod schema验证, 版本迁移 | +| **导入导出** | 批量操作, 冲突解决 | +| **注册表集成** | 与SchemaRegistry双向同步 | + +#### 4.4 依赖关系 + +``` +@objectstack/metadata +├── 直接依赖: +│ ├── @objectstack/spec # 使用Zod schemas验证 +│ ├── @objectstack/core # 日志、服务注册 +│ ├── @objectstack/types # 共享类型定义 +│ ├── glob # 文件匹配 +│ ├── js-yaml # YAML解析 +│ └── chokidar (可选) # 文件监听 +│ +└── 被以下包使用: + ├── @objectstack/cli # 代码生成、scaffolding + ├── @objectstack/runtime # 启动时加载清单 + ├── @objectstack/objectql # 注册表持久化 + └── 未来:@objectstack/hub # 云端元数据同步 +``` + +#### 4.5 包结构 + +``` +packages/metadata/ +├── src/ +│ ├── loaders/ +│ │ ├── filesystem-loader.ts # 从磁盘加载 +│ │ ├── http-loader.ts # 从HTTP/S3加载 +│ │ ├── loader-interface.ts # 加载器抽象接口 +│ │ └── composite-loader.ts # 组合多个加载器 +│ ├── serializers/ +│ │ ├── json-serializer.ts # JSON格式 +│ │ ├── yaml-serializer.ts # YAML格式 +│ │ ├── typescript-serializer.ts # TypeScript模块格式 +│ │ └── serializer-interface.ts # 序列化器抽象接口 +│ ├── validators/ +│ │ ├── schema-validator.ts # Zod验证 +│ │ ├── migration-validator.ts # 版本迁移 +│ │ └── validator-interface.ts # 验证器接口 +│ ├── cache/ +│ │ ├── etag-cache.ts # ETag缓存 +│ │ ├── memory-cache.ts # 内存缓存 +│ │ └── cache-interface.ts # 缓存接口 +│ ├── registry-bridge/ +│ │ ├── schema-registry-sync.ts # SchemaRegistry同步 +│ │ └── event-emitter.ts # 变更通知 +│ ├── exporters/ +│ │ ├── collection-exporter.ts # 批量导出 +│ │ └── selective-exporter.ts # 过滤导出 +│ ├── importers/ +│ │ ├── collection-importer.ts # 批量导入 +│ │ └── conflict-resolver.ts # 冲突解决 +│ ├── metadata-manager.ts # 主编排器 +│ └── index.ts +├── package.json +├── tsconfig.json +└── README.md +``` + +--- + +## 五、实施计划 (Implementation Plan) + +### 阶段1:基础设施 (第1周) +- [ ] 创建包结构和配置 +- [ ] 实现核心接口定义 +- [ ] 实现文件系统加载器 +- [ ] 实现JSON/YAML序列化器 +- [ ] 添加Zod验证集成 +- [ ] 编写单元测试 + +### 阶段2:高级功能 (第2周) +- [ ] 添加TypeScript/JavaScript序列化器 +- [ ] 实现ETag缓存机制 +- [ ] 添加文件监听(开发模式) +- [ ] 实现导出/导入工具 +- [ ] 集成测试 + +### 阶段3:集成 (第3周) +- [ ] 创建SchemaRegistry持久化桥接 +- [ ] 更新Runtime使用metadata manager +- [ ] 集成到CLI命令 +- [ ] 添加HTTP加载器(云同步) +- [ ] 端到端测试 + +### 阶段4:文档与优化 (第4周) +- [ ] 完整API文档 +- [ ] 使用指南和示例 +- [ ] 更新ARCHITECTURE.md +- [ ] 更新PACKAGE-DEPENDENCIES.md +- [ ] 性能基准测试和优化 + +--- + +## 六、使用示例 (Usage Examples) + +### 6.1 基本使用 + +```typescript +import { MetadataManager } from '@objectstack/metadata'; +import { ServiceObject } from '@objectstack/spec/data'; + +// 创建管理器 +const manager = new MetadataManager({ + rootDir: './metadata', + formats: ['typescript', 'json', 'yaml'], + cache: { enabled: true, ttl: 3600 }, + watch: process.env.NODE_ENV === 'development', +}); + +// 加载元数据 +const customer = await manager.load('object', 'customer'); + +// 保存元数据 +await manager.save('object', 'project', projectObject, { + format: 'typescript', + prettify: true, +}); + +// 批量加载 +const objects = await manager.loadCollection('object', { + patterns: ['**/*.object.ts', '**/*.object.json'], +}); +``` + +### 6.2 与SchemaRegistry集成 + +```typescript +import { SchemaRegistry } from '@objectstack/objectql'; +import { SchemaRegistryPersistence } from '@objectstack/metadata'; + +const persistence = new SchemaRegistryPersistence( + SchemaRegistry, + manager +); + +// 从磁盘加载所有对象到注册表 +await persistence.loadAll(); + +// 保存注册表到磁盘 +await persistence.saveAll(); + +// 启用自动同步(文件变更自动更新注册表) +persistence.enableAutoSync(); +``` + +### 6.3 CLI集成 + +```typescript +// CLI命令: objectstack generate object +import { MetadataManager } from '@objectstack/metadata'; + +export async function generateObject(name: string, options: any) { + const manager = new MetadataManager({ rootDir: './src/objects' }); + + const object: ServiceObject = { + name, + label: options.label || name, + fields: { + name: { type: 'text', label: 'Name' }, + }, + }; + + await manager.save('object', name, object, { + format: 'typescript', + }); + + console.log(`✓ Created ${name}.object.ts`); +} +``` + +--- + +## 七、影响分析 (Impact Analysis) + +### 7.1 积极影响 + +| 方面 | 影响 | +|------|------| +| **架构清晰度** | ↑↑ 职责明确,边界清晰 | +| **可维护性** | ↑↑ 独立包,易于维护 | +| **可扩展性** | ↑↑ 插件化设计,易于扩展 | +| **可测试性** | ↑↑ 独立测试,易于mock | +| **开发体验** | ↑ 统一API,减少学习成本 | +| **性能** | ↑ 缓存优化,减少I/O | + +### 7.2 潜在风险 + +| 风险 | 严重度 | 缓解措施 | +|------|--------|---------| +| **包数量增加** | 低 | 职责清晰可抵消复杂度 | +| **性能开销** | 低 | 实施缓存和懒加载 | +| **迁移成本** | 中 | 提供向后兼容和迁移指南 | +| **采用阻力** | 低 | 可选使用,渐进迁移 | + +### 7.3 向后兼容性 + +- ✅ **不破坏现有代码**:SchemaRegistry继续工作 +- ✅ **可选采用**:各包可以逐步迁移 +- ✅ **共存支持**:新旧方案可以并存 +- ✅ **渐进式迁移**:提供迁移路径和工具 + +--- + +## 八、协议定义 (Protocol Definition) + +已在 `@objectstack/spec` 中定义元数据加载器协议: + +**文件位置:** `packages/spec/src/system/metadata-loader.zod.ts` + +**主要协议:** +- `MetadataFormat` - 支持的格式枚举 +- `MetadataStats` - 元数据统计信息 +- `MetadataLoadOptions` - 加载选项 +- `MetadataSaveOptions` - 保存选项 +- `MetadataExportOptions` - 导出选项 +- `MetadataImportOptions` - 导入选项 +- `MetadataLoaderContract` - 加载器接口契约 +- `MetadataManagerConfig` - 管理器配置 + +所有协议使用Zod定义,支持: +- 运行时验证 +- TypeScript类型推导 +- JSON Schema生成 + +--- + +## 九、结论与建议 (Conclusion & Recommendations) + +### 9.1 核心结论 + +经过全面评估,**强烈建议创建独立的 `@objectstack/metadata` 包(Layer 3)**,原因如下: + +1. ✅ **符合微内核架构原则** - 清晰的职责分离 +2. ✅ **不破坏现有包的边界** - 各包保持单一职责 +3. ✅ **为未来扩展奠定基础** - 云同步、版本控制、多租户 +4. ✅ **提升开发体验** - 统一的API和清晰的文档 +5. ✅ **保持向后兼容** - 渐进式迁移,不破坏现有代码 + +### 9.2 立即行动建议 + +1. **审批方案** - 与团队讨论并确认架构决策 +2. **创建包结构** - 按照实施计划Phase 1开始 +3. **更新文档** - 确保架构文档同步更新 +4. **渐进实施** - 先实现核心功能,再扩展高级功能 + +### 9.3 长期价值 + +这个新包将成为ObjectStack元数据管理的基石,支持: +- 📦 **插件生态** - 统一的元数据加载标准 +- ☁️ **云端同步** - Hub/Composer集成 +- 🔄 **版本控制** - 元数据版本管理和迁移 +- 🏢 **多租户** - 租户级别的元数据隔离 +- 🔌 **扩展性** - 自定义加载器和序列化器 + +--- + +## 十、附录 (Appendix) + +### A. 相关文档链接 + +- [完整架构文档](content/docs/developers/metadata-management.mdx) +- [元数据加载器协议](packages/spec/src/system/metadata-loader.zod.ts) +- [协议测试](packages/spec/src/system/metadata-loader.test.ts) +- [ARCHITECTURE.md](ARCHITECTURE.md) +- [PACKAGE-DEPENDENCIES.md](PACKAGE-DEPENDENCIES.md) + +### B. 技术栈 + +| 技术 | 用途 | +|------|------| +| Zod | Schema定义和验证 | +| glob | 文件模式匹配 | +| js-yaml | YAML解析 | +| chokidar | 文件监听 | +| TypeScript | 类型系统 | + +### C. 参考架构 + +借鉴了以下优秀架构: +- **Kubernetes** - CRD (Custom Resource Definitions) +- **VS Code** - Extension API +- **OSGi** - 模块化和插件系统 +- **Eclipse** - Plugin Architecture + +--- + +**评估完成时间**: 2026-01-31 +**下次评审**: Phase 1 实施后 +**状态**: ✅ 建议已提交,等待审批