Skip to content

feat(metadata): implement dual-table projection for queryable metadata#1194

Merged
hotlong merged 6 commits intomainfrom
claude/update-metadata-service-dual-table-projection
Apr 20, 2026
Merged

feat(metadata): implement dual-table projection for queryable metadata#1194
hotlong merged 6 commits intomainfrom
claude/update-metadata-service-dual-table-projection

Conversation

@Claude
Copy link
Copy Markdown
Contributor

@Claude Claude AI commented Apr 20, 2026

Completes Phase 1 of the ObjectOS implementation by enabling metadata to be queried via the Object Protocol. When metadata is saved to sys_metadata (source of truth), it's automatically projected to type-specific tables (sys_object, sys_view, sys_agent, sys_tool, sys_flow) that Studio can query using standard CRUD operations.

Implementation

MetadataProjector Service (packages/metadata/src/projection/)

  • Transforms metadata envelopes into denormalized table rows for efficient querying
  • Supports object, view, agent, tool, flow metadata types
  • Flattens complex structures (fields, capabilities, configs) into queryable columns
  • Works with both IDataDriver and IDataEngine backends

DatabaseLoader Integration

  • Projection triggered automatically in save() and delete() operations
  • New enableProjection config option (default: true)
  • Dual writes: sys_metadata + type-specific table in single transaction context

System Object Registration

  • MetadataPlugin now registers all ObjectOS system objects from @objectstack/objectos
  • Makes sys_object, sys_view, sys_agent, sys_tool, sys_flow available as queryable tables

Usage

// Saving metadata now writes to both tables automatically
await metadataService.register('object', 'account', {
  name: 'account',
  label: 'Account',
  fields: { /* ... */ },
});

// sys_metadata: Full JSON envelope (source of truth)
// sys_object: Denormalized projection (queryable)

// Studio queries via Object Protocol
const objects = await client.data.find('sys_object', {
  filter: { namespace: 'crm', active: true },
  sort: 'name',
});

Architecture

Package Loader
     ↓
sys_metadata (envelope, versioning, package tracking)
     ↓ (automatic projection)
sys_object, sys_view, etc. (denormalized, queryable)
     ↓
Studio UI (Object Protocol API)

This follows the Salesforce CustomObject and ServiceNow sys_dictionary patterns where metadata is stored as queryable data structures rather than opaque configuration blobs.

- Create MetadataProjector service for sys_metadata → type-specific table projections
- Add projection functions for object, view, agent, tool, flow metadata types
- Integrate projection into DatabaseLoader save() and delete() operations
- Register system objects from @objectstack/objectos in MetadataPlugin
- Add enableProjection config option (default: true)
- Projection enables Studio to query metadata via Object Protocol API

Phase 1 implementation complete. Next: Studio integration (Phase 2)

Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/e95ef127-f74b-4ddd-8a6e-fe86e33fe1b0

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
objectstack-demo Ready Ready Preview, Comment Apr 20, 2026 1:34pm
spec Ready Ready Preview, Comment Apr 20, 2026 1:34pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file size/l labels Apr 20, 2026
…rity vulnerabilities

Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/f41c7558-a7d5-4ee9-a23f-ca4216fb23e1

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…r organization and environment IDs

test: add localStorage shim for consistent testing behavior across environments
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements Phase 1 of a dual-table metadata architecture where sys_metadata remains the source-of-truth envelope, while type-specific sys_* tables receive denormalized projections so Studio can query metadata via standard Object Protocol CRUD.

Changes:

  • Added a MetadataProjector service to transform and upsert projections into sys_object, sys_view, sys_agent, sys_tool, sys_flow.
  • Wired projection into DatabaseLoader.save() and DatabaseLoader.delete() with a new enableProjection option (default true).
  • Updated MetadataPlugin to register ObjectOS SystemObjects into the manifest; adjusted Studio test setup for localStorage behavior across Node versions.

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
pnpm-lock.yaml Updates lockfile entries (minimatch override, fastify patch bump).
packages/metadata/src/projection/metadata-projector.ts New projection service implementing denormalization + CRUD into type-specific tables.
packages/metadata/src/projection/index.ts Projection module export barrel.
packages/metadata/src/plugin.ts Registers ObjectOS SystemObjects via manifest service.
packages/metadata/src/loaders/database-loader.ts Adds projection integration and enableProjection option.
packages/metadata/src/index.ts Exposes projection exports from package entrypoint.
packages/metadata/package.json Adds @objectstack/objectos dependency.
packages/adapters/fastify/package.json Bumps fastify peer/dev dependency versions.
package.json Updates pnpm override for minimatch.
apps/studio/test/setup.ts Adds an in-memory Storage shim and reinstalls before each test.
apps/studio/test/ai-chat-panel.test.tsx Updates tests to patch localStorage.setItem directly.
PHASE_1_IMPLEMENTATION.md Adds Phase 1 implementation summary documentation.
OBJECTOS_IMPLEMENTATION.md Adds ObjectOS implementation summary documentation.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

Comment on lines +73 to +82
// 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),
});
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registering Object.values(SystemObjects) also registers ObjectOS’s SysMetadata definition. In @objectstack/objectos this object is defined with namespace: 'sys' and name: 'sys_metadata', which auto-derives tableName as sys_sys_metadata. Because SchemaRegistry prefers short-name matches, this can override the existing sys_metadata mapping and cause ObjectQL reads/writes for sys_metadata to route to sys_sys_metadata (wrong table). Fix by excluding sys_metadata from this registration (keep using the metadata package’s SysMetadataObject), or change the ObjectOS SysMetadata object to use name: 'metadata' (tableName sys_metadata) so it does not shadow the canonical table name.

Copilot uses AI. Check for mistakes.
Comment on lines 104 to +115
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,
});
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With enableProjection defaulting to true, a driver-only DatabaseLoader will attempt to write to projection tables (e.g. sys_object) but ensureSchema() only syncs sys_metadata/history. For SQL drivers this will fail if those projection tables haven’t been created yet, and the projector currently just logs and continues, leaving projections missing. If driver-only mode is supported, ensure projection schemas are synced (e.g., via the corresponding Object definitions) before enabling projection, or default enableProjection to false when engine is not provided.

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +126
/**
* Project metadata to type-specific table
*/
async project(type: string, name: string, data: any): Promise<void> {
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<void> {
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);
}
}

Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MetadataProjector introduces new persistence behavior (upsert/delete across sys_* tables, denormalization rules, and engine vs driver code paths) but there are no unit/integration tests added for it. Add tests that verify projection insert/update/delete behavior (including env/org scoping, JSON column serialization, and engine-vs-driver paths) to prevent regressions.

Copilot uses AI. Check for mistakes.
Comment thread apps/studio/test/setup.ts
Comment on lines +19 to +48
const store = new Map<string, string>();
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 },
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sessionStorage is set to { ...shim }, but spreading an object with an accessor (get length()) converts it into a fixed numeric value, so sessionStorage.length won’t track mutations. Also, { ...shim } copies the same function closures, so sessionStorage and localStorage share the same backing store. Create a separate shim instance per storage (separate Map + real length getter) instead of spreading.

Suggested change
const store = new Map<string, string>();
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 },
const createStorageShim = () => {
const store = new Map<string, string>();
return {
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: createStorageShim(),
});
Object.defineProperty(globalThis, 'sessionStorage', {
configurable: true,
writable: true,
value: createStorageShim(),

Copilot uses AI. Check for mistakes.
Comment on lines +263 to +266
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;
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same cleanup issue here: localStorage.setItem is restored only after the assertion, so failures can leak the patched method into later tests. Use try/finally (or afterEach) to ensure the original implementation is always restored.

Copilot uses AI. Check for mistakes.
Comment on lines 710 to +733
await this._update(this.tableName, existing.id as string, {
metadata: metadataJson,
version,
checksum: newChecksum,
updated_at: now,
state: 'active',
});

// Create history record for update
await this.createHistoryRecord(
existing.id as string,
type,
name,
version,
data,
'update',
previousChecksum
);

// Project to type-specific table
if (this.projector) {
await this.projector.project(type, name, data);
}

Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Projection is executed as separate operations after the sys_metadata write + history write, and there’s no shared transaction context passed to the projector. This does not match the PR description’s “single transaction context” dual-write semantics and can leave sys_metadata and sys_* projections inconsistent if the second write fails or is rolled back elsewhere. Consider using IDataEngine.batch([...], { transaction: true }) (when engine is available) or IDataDriver.beginTransaction/commit/rollback and pass options.transaction through all writes so the envelope + projection update are atomic.

Copilot uses AI. Check for mistakes.
Comment on lines +81 to +90
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 {
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Projection lookup/upsert keys only on { name }. However sys_metadata uniqueness is { type, name, env_id } (and the loader also scopes queries by organizationId/environmentId). If the same metadata name exists in multiple envs/orgs, this projector will overwrite the wrong projection row or delete another tenant’s projection. Include the same scoping keys used by DatabaseLoader.baseFilter() in both the projection record and the where clause (and update the sys_* object schemas/indexes accordingly), or explicitly enforce that projected types are platform-global-only.

Copilot uses AI. Check for mistakes.
Comment on lines +98 to +101
} catch (error) {
// Log but don't fail the main save operation
console.error(`Failed to project ${type}/${name} to ${targetTable}:`, error);
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

project() swallows all projection errors (console.error and continue). This can silently leave projections out-of-sync while save() reports success, making issues hard to detect operationally. Consider (a) injecting a logger instead of using console.error, and (b) making failure behavior configurable (e.g., failOnProjectionError / retry queue), or returning a structured result so callers can surface partial-write warnings.

Copilot uses AI. Check for mistakes.
Comment on lines +100 to +103
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;
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

localStorage.setItem is patched for the test but restored only after the assertion. If the assertion fails (or saveMessages unexpectedly throws), the original method won’t be restored and could leak into subsequent tests. Wrap the patch/restore in try/finally to guarantee cleanup.

Copilot uses AI. Check for mistakes.
@hotlong hotlong merged commit 0a24a37 into main Apr 20, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants