diff --git a/OBJECTOS_IMPLEMENTATION.md b/OBJECTOS_IMPLEMENTATION.md new file mode 100644 index 000000000..b9af55495 --- /dev/null +++ b/OBJECTOS_IMPLEMENTATION.md @@ -0,0 +1,185 @@ +# ObjectOS Implementation Summary + +## Overview + +Successfully implemented the ObjectOS layer - a new `@objectstack/objectos` package containing system runtime object definitions that represent metadata as queryable data. + +## Architecture Decision + +Based on architectural discussions, we established: + +1. **Location**: `packages/objectos` (NOT `packages/plugins/plugin-system`) + - Rationale: System objects are core infrastructure, not optional plugins + - ObjectOS represents the OS-level primitives of the platform + +2. **Dual-Table Pattern**: Keep BOTH systems (do NOT deprecate `sys_metadata`) + - `sys_metadata`: Source of truth for package management, version control, deployment + - Type-specific tables (`sys_object`, `sys_view`, etc.): Queryable data for UI/reporting + +## Package Structure + +``` +packages/objectos/ +├── src/ +│ ├── objects/ +│ │ ├── sys-metadata.object.ts # Generic metadata envelope +│ │ ├── sys-object.object.ts # Object definitions +│ │ ├── sys-view.object.ts # View definitions +│ │ ├── sys-agent.object.ts # AI Agent definitions +│ │ ├── sys-tool.object.ts # AI Tool definitions +│ │ ├── sys-flow.object.ts # Flow definitions +│ │ └── index.ts +│ ├── index.ts # Package entry point +│ └── registry.ts # System object registry +├── package.json +├── tsconfig.json +├── tsup.config.ts +└── README.md +``` + +## System Objects Implemented + +### 1. sys_metadata (Generic Envelope) +- **Purpose**: Source of truth for package management +- **Features**: Version control, checksums, package ownership, deployment tracking +- **Fields**: 20+ fields including version, checksum, package_id, managed_by, scope + +### 2. sys_object (Queryable Object Definitions) +- **Purpose**: Browse/filter/search object definitions in Studio +- **Features**: Denormalized data, complex fields as JSON +- **Fields**: 30+ fields including fields_json, capabilities_json, field_count + +### 3. sys_view (Queryable View Definitions) +- **Purpose**: Manage view metadata through Object Protocol +- **Features**: View type filtering, object references +- **Fields**: columns_json, filters_json, sort_json, config_json + +### 4. sys_agent (AI Agent Definitions) +- **Purpose**: AI agent configuration as data +- **Features**: Model config, tools/skills management +- **Fields**: model, temperature, system_prompt, tools_json, skills_json + +### 5. sys_tool (AI Tool Definitions) +- **Purpose**: AI tool registry as queryable data +- **Features**: Parameter schemas, handler code +- **Fields**: parameters_json, handler_code + +### 6. sys_flow (Automation Flow Definitions) +- **Purpose**: Flow metadata management +- **Features**: Flow types, trigger configuration +- **Fields**: flow_type, nodes_json, edges_json, trigger_type + +## Key Features + +1. **Metadata as Data** + - All metadata types are queryable using Object Protocol + - Same CRUD operations as business data + - Consistent API: `/api/v1/data/sys_object`, `/api/v1/data/sys_view` + +2. **Dual-Table Architecture** + ``` + Package Loader + ↓ + sys_metadata (source of truth) + ↓ (projection) + sys_object, sys_view, etc. (queryable) + ↓ + Studio UI (auto-generated) + ``` + +3. **Version Management** + - `sys_metadata` tracks all versions + - `sys_metadata_history` table for history + - Checksum-based change detection + - Package upgrade/downgrade support + +4. **Auto-Generated UI** + - Studio uses Object Protocol + - No custom UI code per metadata type + - Leverage grid/form/kanban views + +## Industry Alignment + +- **Salesforce**: CustomObject, CustomField (queryable metadata) +- **ServiceNow**: sys_db_object, sys_dictionary (table-based metadata) +- **Kubernetes**: CRDs as structured resources + +## Next Steps + +### Phase 1: Integration (Immediate) ✅ COMPLETED +- [x] Update `packages/metadata` service to support projection +- [x] Implement dual-table sync logic +- [x] Register system objects in runtime bootstrap + +**Implementation Details:** +- Created `MetadataProjector` service in `packages/metadata/src/projection/` +- Integrated projection into `DatabaseLoader.save()` and `DatabaseLoader.delete()` +- Added projection functions for each metadata type: object, view, agent, tool, flow +- Updated `MetadataPlugin` to register all system objects from `@objectstack/objectos` +- Projection is enabled by default, can be disabled via `enableProjection: false` option + +### Phase 2: Studio Integration (Next) +- [ ] Update Studio to query type-specific tables +- [ ] Use `/api/v1/data/sys_object` for browsing +- [ ] Auto-generate metadata forms + +### Phase 3: Testing & Documentation (Later) +- [ ] Add comprehensive test coverage +- [ ] Update documentation +- [ ] Create migration guides + +## Usage Example + +```typescript +import { SystemObjects } from '@objectstack/objectos'; + +// Register all system objects during bootstrap +for (const [name, definition] of Object.entries(SystemObjects)) { + await kernel.metadata.register('object', name, definition, { + scope: 'system', + isSystem: true, + managedBy: 'platform', + }); +} + +// Query metadata using Object Protocol +const objects = await client.data.find('sys_object', { + filter: { namespace: 'crm' }, + sort: 'name', +}); + +// Studio auto-generates UI + + +``` + +## Benefits + +1. ✅ **Unified Protocol**: One protocol for both data and metadata +2. ✅ **Auto-Generated UI**: Studio reuses existing components +3. ✅ **Better DX**: Consistent API for all entity types +4. ✅ **Version Control**: Full history via sys_metadata_history +5. ✅ **Package Management**: Track ownership and deployments +6. ✅ **Industry Standard**: Follows Salesforce/ServiceNow patterns + +## Files Created + +- `/home/runner/work/framework/framework/packages/objectos/package.json` +- `/home/runner/work/framework/framework/packages/objectos/tsconfig.json` +- `/home/runner/work/framework/framework/packages/objectos/tsup.config.ts` +- `/home/runner/work/framework/framework/packages/objectos/README.md` +- `/home/runner/work/framework/framework/packages/objectos/src/index.ts` +- `/home/runner/work/framework/framework/packages/objectos/src/registry.ts` +- `/home/runner/work/framework/framework/packages/objectos/src/objects/index.ts` +- `/home/runner/work/framework/framework/packages/objectos/src/objects/sys-metadata.object.ts` +- `/home/runner/work/framework/framework/packages/objectos/src/objects/sys-object.object.ts` +- `/home/runner/work/framework/framework/packages/objectos/src/objects/sys-view.object.ts` +- `/home/runner/work/framework/framework/packages/objectos/src/objects/sys-agent.object.ts` +- `/home/runner/work/framework/framework/packages/objectos/src/objects/sys-tool.object.ts` +- `/home/runner/work/framework/framework/packages/objectos/src/objects/sys-flow.object.ts` + +## Conclusion + +The ObjectOS package establishes a clean architectural foundation for treating metadata as queryable data. This enables auto-generated Studio UI, unified APIs, and follows industry best practices from Salesforce, ServiceNow, and Kubernetes. + +The dual-table architecture preserves the benefits of `sys_metadata` for package management while adding queryability through type-specific tables. diff --git a/PHASE_1_IMPLEMENTATION.md b/PHASE_1_IMPLEMENTATION.md new file mode 100644 index 000000000..ce413e8bf --- /dev/null +++ b/PHASE_1_IMPLEMENTATION.md @@ -0,0 +1,139 @@ +# Phase 1 Implementation Summary: Dual-Table Metadata Projection + +## Overview + +Successfully implemented the dual-table projection logic for the metadata service. This establishes the architectural foundation for treating metadata as queryable data following industry best practices from Salesforce, ServiceNow, and Kubernetes. + +## Architecture + +The dual-table pattern maintains two layers: + +1. **sys_metadata** (Source of Truth) + - Package management + - Version control + - Deployment tracking + - History via sys_metadata_history + +2. **Type-Specific Tables** (Queryable Projections) + - sys_object — Object definitions + - sys_view — View configurations + - sys_agent — AI agent metadata + - sys_tool — AI tool registry + - sys_flow — Automation flows + +## Implementation Details + +### 1. MetadataProjector Service + +Created `packages/metadata/src/projection/metadata-projector.ts` with: + +- **Project transformation functions** for each metadata type +- **Denormalization logic** to flatten complex structures for querying +- **Support for both IDataDriver and IDataEngine** (ObjectQL) +- **Automatic CRUD operations** on projection tables + +Key methods: +- `project(type, name, data)` — Create/update projection +- `deleteProjection(type, name)` — Remove projection +- `transformToProjection(type, name, data)` — Type-specific transformation + +### 2. DatabaseLoader Integration + +Updated `packages/metadata/src/loaders/database-loader.ts`: + +- Added `enableProjection` configuration option (default: true) +- Integrated `MetadataProjector` into save() flow +- Added projection cleanup in delete() flow +- Projection occurs AFTER sys_metadata save (async safety) + +### 3. System Object Registration + +Updated `packages/metadata/src/plugin.ts`: + +- Added dependency on `@objectstack/objectos` +- Registered all system objects from SystemObjects registry +- Objects registered via manifest service during plugin init() +- Includes: sys_object, sys_view, sys_agent, sys_tool, sys_flow + +## Projection Mapping + +Each metadata type is projected with denormalized fields for efficient querying: + +### Object Projection (object → sys_object) +- Complex structures (fields, indexes, validations) → JSON columns +- Capabilities → individual boolean columns for filtering +- Denormalized field_count for sorting/filtering + +### View Projection (view → sys_view) +- Columns, filters, sort, config → JSON columns +- Display options → individual columns +- Object reference for joins + +### Agent Projection (agent → sys_agent) +- Model configuration → individual columns +- Tools, skills → JSON columns +- Memory settings → individual columns + +### Tool Projection (tool → sys_tool) +- Parameters schema → JSON column +- Handler code → text column + +### Flow Projection (flow → sys_flow) +- Nodes, edges, variables → JSON columns +- Trigger configuration → individual columns +- Active status for filtering + +## Benefits Achieved + +1. **Unified Query Protocol**: Metadata can be queried using Object Protocol API +2. **Studio Auto-Generation**: UI can use `/api/v1/data/sys_*` endpoints +3. **Efficient Filtering**: Denormalized fields enable fast queries +4. **Preserved History**: sys_metadata maintains full version control +5. **Package Tracking**: All projections include package_id and managed_by + +## Files Changed + +### New Files +- `packages/metadata/src/projection/metadata-projector.ts` — Projection service +- `packages/metadata/src/projection/index.ts` — Module exports + +### Modified Files +- `packages/metadata/src/loaders/database-loader.ts` — Added projection integration +- `packages/metadata/src/plugin.ts` — Registered system objects +- `packages/metadata/src/index.ts` — Exported projection module +- `packages/metadata/package.json` — Added @objectstack/objectos dependency +- `OBJECTOS_IMPLEMENTATION.md` — Updated Phase 1 status + +## Usage Example + +```typescript +// Projection happens automatically when saving metadata +await metadataService.register('object', 'account', { + name: 'account', + label: 'Account', + fields: { /* ... */ }, + // ... object definition +}); + +// Results in TWO database writes: +// 1. sys_metadata: Full envelope with JSON payload +// 2. sys_object: Denormalized projection with queryable columns + +// Studio can now query via Object Protocol +const objects = await client.data.find('sys_object', { + filter: { namespace: 'crm' }, + sort: 'name', +}); +``` + +## Next Steps + +Phase 2 will focus on Studio integration: +- Update Studio to use type-specific table queries +- Auto-generate metadata management UI +- Leverage existing grid/form/kanban components + +Phase 3 will add: +- Comprehensive test coverage +- Documentation updates +- Migration guides for existing deployments diff --git a/apps/studio/test/ai-chat-panel.test.tsx b/apps/studio/test/ai-chat-panel.test.tsx index eec06c90b..7302bdef8 100644 --- a/apps/studio/test/ai-chat-panel.test.tsx +++ b/apps/studio/test/ai-chat-panel.test.tsx @@ -97,10 +97,10 @@ describe('use-ai-chat-panel', () => { }); it('does not throw when localStorage is unavailable', () => { - const originalSetItem = Storage.prototype.setItem; - Storage.prototype.setItem = () => { throw new Error('QuotaExceeded'); }; + const originalSetItem = localStorage.setItem.bind(localStorage); + localStorage.setItem = () => { throw new Error('QuotaExceeded'); }; expect(() => saveMessages([makeMsg({ id: '1', role: 'user', content: 'A' })])).not.toThrow(); - Storage.prototype.setItem = originalSetItem; + localStorage.setItem = originalSetItem; }); }); }); @@ -260,10 +260,10 @@ describe('Agent Selector', () => { }); it('should not throw when localStorage is unavailable', () => { - const originalSetItem = Storage.prototype.setItem; - Storage.prototype.setItem = () => { throw new Error('QuotaExceeded'); }; + const originalSetItem = localStorage.setItem.bind(localStorage); + localStorage.setItem = () => { throw new Error('QuotaExceeded'); }; expect(() => saveSelectedAgent('metadata_assistant')).not.toThrow(); - Storage.prototype.setItem = originalSetItem; + localStorage.setItem = originalSetItem; }); }); diff --git a/apps/studio/test/setup.ts b/apps/studio/test/setup.ts index 09a3c485d..149b9db6d 100644 --- a/apps/studio/test/setup.ts +++ b/apps/studio/test/setup.ts @@ -7,7 +7,55 @@ import '@testing-library/jest-dom/vitest'; import { cleanup } from '@testing-library/react'; -import { afterEach } from 'vitest'; +import { afterEach, beforeEach } from 'vitest'; + +// Node 22+ ships an experimental built-in `localStorage` that is a plain +// empty object without Storage API methods. When `@vitest-environment +// happy-dom` runs, that global can shadow happy-dom's Storage implementation, +// leaving tests with `localStorage.getItem is not a function`. Install a +// minimal in-memory Storage-compatible shim to guarantee consistent behavior +// across Node versions and environments. +function installStorageShim(): void { + const store = new Map(); + const shim = { + get length() { + return store.size; + }, + clear(): void { + store.clear(); + }, + getItem(key: string): string | null { + return store.has(key) ? (store.get(key) as string) : null; + }, + setItem(key: string, value: string): void { + store.set(String(key), String(value)); + }, + removeItem(key: string): void { + store.delete(key); + }, + key(index: number): string | null { + return Array.from(store.keys())[index] ?? null; + }, + }; + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + writable: true, + value: shim, + }); + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + writable: true, + value: { ...shim }, + }); +} + +installStorageShim(); + +// Re-install before each test so tests that mutate localStorage.setItem +// (to simulate quota errors) cannot leak across other tests. +beforeEach(() => { + installStorageShim(); +}); // Cleanup after each test afterEach(() => { diff --git a/package.json b/package.json index 5dcf9d890..057934754 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268", "pnpm": { "overrides": { - "minimatch@<10.2.1": "10.2.1" + "minimatch@<10.2.3": "10.2.3" }, "ignoredBuiltDependencies": [ "@nestjs/core", diff --git a/packages/adapters/fastify/package.json b/packages/adapters/fastify/package.json index f5032a264..a190c1b83 100644 --- a/packages/adapters/fastify/package.json +++ b/packages/adapters/fastify/package.json @@ -18,11 +18,11 @@ }, "peerDependencies": { "@objectstack/runtime": "workspace:^", - "fastify": "^5.8.2" + "fastify": "^5.8.5" }, "devDependencies": { "@objectstack/runtime": "workspace:*", - "fastify": "^5.8.4", + "fastify": "^5.8.5", "typescript": "^6.0.2", "vitest": "^4.1.4" }, diff --git a/packages/metadata/package.json b/packages/metadata/package.json index f96eae46b..053083df1 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -39,6 +39,7 @@ ], "dependencies": { "@objectstack/core": "workspace:*", + "@objectstack/objectos": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", "chokidar": "^5.0.0", diff --git a/packages/metadata/src/index.ts b/packages/metadata/src/index.ts index 071761ecb..1454e95e7 100644 --- a/packages/metadata/src/index.ts +++ b/packages/metadata/src/index.ts @@ -13,6 +13,9 @@ export { MetadataManager, type WatchCallback, type MetadataManagerOptions } from // Plugin export { MetadataPlugin } from './plugin.js'; +// Projection +export { MetadataProjector, type MetadataProjectorOptions } from './projection/index.js'; + // Loaders export { type MetadataLoader } from './loaders/loader-interface.js'; export { MemoryLoader } from './loaders/memory-loader.js'; diff --git a/packages/metadata/src/loaders/database-loader.ts b/packages/metadata/src/loaders/database-loader.ts index 2f147bdb0..73d724b38 100644 --- a/packages/metadata/src/loaders/database-loader.ts +++ b/packages/metadata/src/loaders/database-loader.ts @@ -24,6 +24,7 @@ import { SysMetadataHistoryObject } from '../objects/sys-metadata-history.object import type { IDataDriver, IDataEngine } from '@objectstack/spec/contracts'; import type { MetadataLoader } from './loader-interface.js'; import { calculateChecksum } from '../utils/metadata-history-utils.js'; +import { MetadataProjector } from '../projection/metadata-projector.js'; /** * Configuration for the DatabaseLoader. @@ -54,6 +55,9 @@ export interface DatabaseLoaderOptions { /** Enable history tracking (default: true) */ trackHistory?: boolean; + + /** Enable metadata projection to type-specific tables (default: true) */ + enableProjection?: boolean; } /** @@ -84,6 +88,8 @@ export class DatabaseLoader implements MetadataLoader { private trackHistory: boolean; private schemaReady = false; private historySchemaReady = false; + private enableProjection: boolean; + private projector?: MetadataProjector; constructor(options: DatabaseLoaderOptions) { if (!options.driver && !options.engine) { @@ -96,6 +102,17 @@ export class DatabaseLoader implements MetadataLoader { this.organizationId = options.organizationId; this.environmentId = options.environmentId; this.trackHistory = options.trackHistory !== false; // Default to true + this.enableProjection = options.enableProjection !== false; // Default to true + + // Initialize projector if projection is enabled + if (this.enableProjection) { + this.projector = new MetadataProjector({ + driver: this.driver, + engine: this.engine, + organizationId: this.organizationId, + environmentId: this.environmentId, + }); + } } // ========================================== @@ -709,6 +726,11 @@ export class DatabaseLoader implements MetadataLoader { previousChecksum ); + // Project to type-specific table + if (this.projector) { + await this.projector.project(type, name, data); + } + return { success: true, path: `datasource://${this.tableName}/${type}/${name}`, @@ -746,6 +768,11 @@ export class DatabaseLoader implements MetadataLoader { 'create' ); + // Project to type-specific table + if (this.projector) { + await this.projector.project(type, name, data); + } + return { success: true, path: `datasource://${this.tableName}/${type}/${name}`, @@ -780,6 +807,11 @@ export class DatabaseLoader implements MetadataLoader { // Delete from the main metadata table using the record's ID await this._delete(this.tableName, existing.id as string); + + // Delete projection from type-specific table + if (this.projector) { + await this.projector.deleteProjection(type, name); + } } } diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index 571e95c60..e3ce52d61 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -6,6 +6,7 @@ import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; import type { MetadataPluginConfig } from '@objectstack/spec/kernel'; import { SysMetadataObject } from './objects/sys-metadata.object.js'; import { SysMetadataHistoryObject } from './objects/sys-metadata-history.object.js'; +import { SystemObjects } from '@objectstack/objectos'; export interface MetadataPluginOptions { rootDir?: string; @@ -56,7 +57,10 @@ export class MetadataPlugin implements Plugin { // Register metadata system objects via the manifest service (if available). // MetadataPlugin may init before ObjectQLPlugin, so wrap in try/catch. try { - ctx.getService<{ register(m: any): void }>('manifest').register({ + const manifestService = ctx.getService<{ register(m: any): void }>('manifest'); + + // Register the metadata envelope tables + manifestService.register({ id: 'com.objectstack.metadata', name: 'Metadata', version: '1.0.0', @@ -65,6 +69,22 @@ export class MetadataPlugin implements Plugin { namespace: 'sys', objects: [SysMetadataObject, SysMetadataHistoryObject], }); + + // Register the queryable system objects from @objectstack/objectos + manifestService.register({ + id: 'com.objectstack.objectos', + name: 'ObjectOS System Objects', + version: '1.0.0', + type: 'plugin', + scope: 'platform', + namespace: 'sys', + objects: Object.values(SystemObjects), + }); + + ctx.logger.info('Registered system metadata objects', { + metadata: ['sys_metadata', 'sys_metadata_history'], + objectos: Object.keys(SystemObjects), + }); } catch { // ObjectQL not loaded yet — objects will be discovered via legacy fallback } diff --git a/packages/metadata/src/projection/index.ts b/packages/metadata/src/projection/index.ts new file mode 100644 index 000000000..9ebdcc041 --- /dev/null +++ b/packages/metadata/src/projection/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Metadata Projection + * + * Dual-table architecture: sys_metadata → type-specific tables + */ + +export { MetadataProjector, type MetadataProjectorOptions } from './metadata-projector.js'; diff --git a/packages/metadata/src/projection/metadata-projector.ts b/packages/metadata/src/projection/metadata-projector.ts new file mode 100644 index 000000000..e510e6161 --- /dev/null +++ b/packages/metadata/src/projection/metadata-projector.ts @@ -0,0 +1,361 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Metadata Projection Service + * + * Implements the dual-table architecture pattern: + * - sys_metadata: Source of truth for package management, versioning + * - Type-specific tables (sys_object, sys_view, etc.): Queryable projections + * + * When metadata is saved to sys_metadata, this service projects it into + * the appropriate type-specific table so Studio can query it via Object Protocol. + */ + +import type { IDataDriver, IDataEngine } from '@objectstack/spec/contracts'; + +/** + * Configuration for the MetadataProjector + */ +export interface MetadataProjectorOptions { + /** The IDataDriver instance to use for database operations */ + driver?: IDataDriver; + + /** The IDataEngine (ObjectQL) instance — preferred over raw driver */ + engine?: IDataEngine; + + /** Organization ID for multi-tenant isolation */ + organizationId?: string; + + /** Environment ID — null = platform-global, set = env-scoped */ + environmentId?: string; +} + +/** + * MetadataProjector + * + * Handles projection from sys_metadata to type-specific tables. + */ +export class MetadataProjector { + private driver?: IDataDriver; + private engine?: IDataEngine; + /** Reserved for future multi-tenant projection scoping */ + readonly scope: { organizationId?: string; environmentId?: string }; + + // Map of metadata types to their target table names + private readonly typeTableMap: Record = { + object: 'sys_object', + view: 'sys_view', + agent: 'sys_agent', + tool: 'sys_tool', + flow: 'sys_flow', + // Add more as needed: dashboard, app, action, workflow, etc. + }; + + constructor(options: MetadataProjectorOptions) { + if (!options.driver && !options.engine) { + throw new Error('MetadataProjector requires either a driver or engine'); + } + this.driver = options.driver; + this.engine = options.engine; + this.scope = { + organizationId: options.organizationId, + environmentId: options.environmentId, + }; + } + + /** + * Project metadata to type-specific table + */ + async project(type: string, name: string, data: any): Promise { + const targetTable = this.typeTableMap[type]; + if (!targetTable) { + // Not all metadata types have projections (e.g., 'field' might not) + return; + } + + const projectedData = this.transformToProjection(type, name, data); + if (!projectedData) { + return; + } + + try { + // Check if projection already exists + const existing = await this._findOne(targetTable, { + where: { name }, + }); + + if (existing) { + // Update existing projection + await this._update(targetTable, existing.id as string, projectedData); + } else { + // Create new projection + const id = this.generateId(); + await this._create(targetTable, { + id, + ...projectedData, + }); + } + } catch (error) { + // Log but don't fail the main save operation + console.error(`Failed to project ${type}/${name} to ${targetTable}:`, error); + } + } + + /** + * Delete projection from type-specific table + */ + async deleteProjection(type: string, name: string): Promise { + const targetTable = this.typeTableMap[type]; + if (!targetTable) { + return; + } + + try { + // Find the projection + const existing = await this._findOne(targetTable, { + where: { name }, + }); + + if (existing) { + await this._delete(targetTable, existing.id as string); + } + } catch (error) { + console.error(`Failed to delete projection ${type}/${name} from ${targetTable}:`, error); + } + } + + /** + * Transform metadata into projection record + */ + private transformToProjection(type: string, name: string, data: any): Record | null { + const now = new Date().toISOString(); + + switch (type) { + case 'object': + return this.projectObject(name, data, now); + case 'view': + return this.projectView(name, data, now); + case 'agent': + return this.projectAgent(name, data, now); + case 'tool': + return this.projectTool(name, data, now); + case 'flow': + return this.projectFlow(name, data, now); + default: + return null; + } + } + + /** + * Project object metadata to sys_object + */ + private projectObject(name: string, data: any, now: string): Record { + return { + name, + label: data.label || name, + plural_label: data.pluralLabel || data.label || name, + description: data.description || '', + icon: data.icon || 'database', + namespace: data.namespace || 'default', + tags: Array.isArray(data.tags) ? data.tags.join(',') : (data.tags || ''), + active: data.active !== false, + is_system: data.isSystem || false, + abstract: data.abstract || false, + datasource: data.datasource || 'default', + table_name: data.tableName || name, + // Serialize complex structures as JSON + fields_json: data.fields ? JSON.stringify(data.fields) : null, + indexes_json: data.indexes ? JSON.stringify(data.indexes) : null, + validations_json: data.validations ? JSON.stringify(data.validations) : null, + state_machines_json: data.stateMachines ? JSON.stringify(data.stateMachines) : null, + capabilities_json: data.enable ? JSON.stringify(data.enable) : null, + // Denormalized fields + field_count: data.fields ? Object.keys(data.fields).length : 0, + display_name_field: data.displayNameField || null, + title_format: data.titleFormat || null, + compact_layout: Array.isArray(data.compactLayout) ? data.compactLayout.join(',') : (data.compactLayout || null), + // Capabilities (denormalized for easier querying) + track_history: data.enable?.trackHistory || false, + searchable: data.enable?.searchable !== false, + api_enabled: data.enable?.apiEnabled !== false, + files: data.enable?.files || false, + feeds: data.enable?.feeds || false, + activities: data.enable?.activities || false, + trash: data.enable?.trash !== false, + mru: data.enable?.mru !== false, + clone: data.enable?.clone !== false, + // Package management + package_id: data.packageId || null, + managed_by: data.managedBy || 'user', + // Audit + created_by: data.createdBy || null, + created_at: data.createdAt || now, + updated_by: data.updatedBy || null, + updated_at: now, + }; + } + + /** + * Project view metadata to sys_view + */ + private projectView(name: string, data: any, now: string): Record { + return { + name, + label: data.label || name, + description: data.description || '', + object_name: data.object || '', + view_type: data.type || 'grid', + // Serialize configurations as JSON + columns_json: data.columns ? JSON.stringify(data.columns) : null, + filters_json: data.filters ? JSON.stringify(data.filters) : null, + sort_json: data.sort ? JSON.stringify(data.sort) : null, + config_json: data.config ? JSON.stringify(data.config) : null, + // Display options + page_size: data.pageSize || 25, + show_search: data.showSearch !== false, + show_filters: data.showFilters !== false, + // Classification + namespace: data.namespace || 'default', + // Package management + package_id: data.packageId || null, + managed_by: data.managedBy || 'user', + // Audit + created_by: data.createdBy || null, + created_at: data.createdAt || now, + updated_by: data.updatedBy || null, + updated_at: now, + }; + } + + /** + * Project agent metadata to sys_agent + */ + private projectAgent(name: string, data: any, now: string): Record { + return { + name, + label: data.label || name, + description: data.description || '', + agent_type: data.type || 'conversational', + // Model configuration + model: data.model || null, + temperature: data.temperature ?? 0.7, + max_tokens: data.maxTokens || null, + top_p: data.topP || null, + // System prompt + system_prompt: data.systemPrompt || null, + // Tools and skills as JSON + tools_json: data.tools ? JSON.stringify(data.tools) : null, + skills_json: data.skills ? JSON.stringify(data.skills) : null, + // Memory + memory_enabled: data.memoryEnabled || false, + memory_window: data.memoryWindow || 10, + // Classification + namespace: data.namespace || 'default', + // Package management + package_id: data.packageId || null, + managed_by: data.managedBy || 'user', + // Audit + created_by: data.createdBy || null, + created_at: data.createdAt || now, + updated_by: data.updatedBy || null, + updated_at: now, + }; + } + + /** + * Project tool metadata to sys_tool + */ + private projectTool(name: string, data: any, now: string): Record { + return { + name, + label: data.label || name, + description: data.description || '', + // Parameters and implementation + parameters_json: data.parameters ? JSON.stringify(data.parameters) : null, + handler_code: data.handler || null, + // Classification + namespace: data.namespace || 'default', + // Package management + package_id: data.packageId || null, + managed_by: data.managedBy || 'user', + // Audit + created_by: data.createdBy || null, + created_at: data.createdAt || now, + updated_by: data.updatedBy || null, + updated_at: now, + }; + } + + /** + * Project flow metadata to sys_flow + */ + private projectFlow(name: string, data: any, now: string): Record { + return { + name, + label: data.label || name, + description: data.description || '', + flow_type: data.type || 'autolaunched', + // Flow definition + nodes_json: data.nodes ? JSON.stringify(data.nodes) : null, + edges_json: data.edges ? JSON.stringify(data.edges) : null, + variables_json: data.variables ? JSON.stringify(data.variables) : null, + // Trigger configuration + trigger_type: data.triggerType || null, + trigger_object: data.triggerObject || null, + // Status + active: data.active || false, + // Classification + namespace: data.namespace || 'default', + // Package management + package_id: data.packageId || null, + managed_by: data.managedBy || 'user', + // Audit + created_by: data.createdBy || null, + created_at: data.createdAt || now, + updated_by: data.updatedBy || null, + updated_at: now, + }; + } + + // ========================================== + // Internal CRUD helpers (driver vs engine) + // ========================================== + + private async _findOne(table: string, query: Record): Promise | null> { + if (this.engine) { + return this.engine.findOne(table, query as any); + } + return this.driver!.findOne(table, { object: table, ...query } as any); + } + + private async _create(table: string, data: Record): Promise> { + if (this.engine) { + return this.engine.insert(table, data); + } + return this.driver!.create(table, data); + } + + private async _update(table: string, id: string, data: Record): Promise> { + if (this.engine) { + return this.engine.update(table, { id, ...data }); + } + return this.driver!.update(table, id, data); + } + + private async _delete(table: string, id: string): Promise { + if (this.engine) { + return this.engine.delete(table, { where: { id } } as any); + } + return this.driver!.delete(table, id); + } + + /** + * Generate a simple unique ID + */ + private generateId(): string { + if (typeof globalThis.crypto !== 'undefined' && typeof globalThis.crypto.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + return `proj_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fb60438a..1f142ee00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - minimatch@<10.2.1: 10.2.1 + minimatch@<10.2.3: 10.2.3 importers: @@ -477,8 +477,8 @@ importers: specifier: workspace:* version: link:../../runtime fastify: - specifier: ^5.8.4 - version: 5.8.4 + specifier: ^5.8.5 + version: 5.8.5 typescript: specifier: ^6.0.2 version: 6.0.2 @@ -761,6 +761,9 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../core + '@objectstack/objectos': + specifier: workspace:* + version: link:../objectos '@objectstack/spec': specifier: workspace:* version: link:../spec @@ -5299,8 +5302,8 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fastify@5.8.4: - resolution: {integrity: sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==} + fastify@5.8.5: + resolution: {integrity: sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==} fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -6386,9 +6389,9 @@ packages: mingo@7.2.1: resolution: {integrity: sha512-MEIQPOSJS2sVCueyQeE2rzgEeW3HpIIhizPbeuwD4v7+miVj7NI3ZVPqqw8t3YPIWCivpIaXA4KsoRI7koyNOA==} - minimatch@10.2.1: - resolution: {integrity: sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==} - engines: {node: 20 || >=22} + minimatch@10.2.3: + resolution: {integrity: sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==} + engines: {node: 18 || 20 || >=22} minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} @@ -10843,7 +10846,7 @@ snapshots: leven: 3.1.0 markdown-it: 14.1.1 mime: 1.6.0 - minimatch: 10.2.1 + minimatch: 10.2.3 parse-semver: 1.1.1 read: 1.0.7 secretlint: 10.2.2 @@ -11764,7 +11767,7 @@ snapshots: fast-uri@3.1.0: {} - fastify@5.8.4: + fastify@5.8.5: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.2.0 @@ -11812,7 +11815,7 @@ snapshots: filelist@1.0.6: dependencies: - minimatch: 10.2.1 + minimatch: 10.2.3 fill-range@7.1.1: dependencies: @@ -12048,14 +12051,14 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 4.2.3 - minimatch: 10.2.1 + minimatch: 10.2.3 minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 2.0.2 glob@13.0.6: dependencies: - minimatch: 10.2.5 + minimatch: 10.2.3 minipass: 7.1.3 path-scurry: 2.0.2 @@ -13154,7 +13157,7 @@ snapshots: mingo@7.2.1: {} - minimatch@10.2.1: + minimatch@10.2.3: dependencies: brace-expansion: 5.0.5