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