Skip to content

Implement Schema Management Service with Migration Infrastructure #106

Closed

Description

@malibio

Overview

Create schema management system with migration infrastructure for user-defined entity types using Pure JSON schema-as-node pattern. Implements lazy migration strategy to enable safe schema evolution without ALTER TABLE operations.


Table of Contents

  1. Architecture Overview
  2. 7-Phase Implementation Plan
  3. Key Enhancements
  4. Acceptance Criteria
  5. Sub-Agent Delegation Guide
  6. Commit Message Templates
  7. Common Pitfalls & Solutions
  8. Testing Strategy
  9. Related Issues

Architecture Overview

Schema-as-Node Pattern

NodeSpace stores schemas as regular nodes with node_type = 'schema':

{
  "id": "task",              // Convention: id = type_name
  "node_type": "schema",
  "content": "Task",
  "properties": {
    "is_core": true,
    "version": 2,            // Schema version (increments on changes)
    "description": "Task tracking schema",
    "fields": [
      {
        "name": "status",
        "type": "enum",
        "protection": "core",        // Cannot delete/modify
        "core_values": ["OPEN", "IN_PROGRESS", "DONE"],
        "user_values": ["BLOCKED"],  // User can add/remove
        "extensible": true,
        "indexed": true,
        "required": true,
        "default": "OPEN"
      }
    ]
  }
}

Node Instance Versioning

Each node instance tracks which schema version validated it:

{
  "id": "task-123",
  "node_type": "task",
  "content": "Implement schema migration",
  "properties": {
    "_schema_version": 2,    // Tracks schema version
    "status": "IN_PROGRESS",
    "due_date": "2025-01-15"
  }
}

Lazy Migration Flow

User/Agent requests node
        ↓
NodeService.get_node(id)
        ↓
Check: node._schema_version < schema.version?
        ↓ YES
Apply migration transforms (v1→v2, v2→v3, etc.)
        ↓
Update node with new properties + version
        ↓
Persist to database
        ↓
Return migrated node

Protection Levels

Level Modifiable Deletable Use Case
core ❌ No ❌ No Foundation fields UI depends on
user ✅ Yes ✅ Yes User-added customizations
system ❌ No ❌ No Auto-managed internal fields

Why Lazy Migration?

Desktop Application Reality:

  • 10,000+ user installations
  • Cannot coordinate bulk migrations
  • No downtime windows
  • Catastrophic failure = lost user data

Lazy Migration Benefits:

  • ✅ No bulk operations (desktop-safe)
  • ✅ Distributed load (users migrate as they access)
  • ✅ Backward compatible (old nodes keep working)
  • ✅ Testable (each migration is pure function)
  • ✅ Rollback-friendly (keep old parsers indefinitely)

7-Phase Implementation Plan

This issue will be implemented across 7 independent, testable phases. Each phase produces a commit with detailed handoff documentation for the next AI agent/developer session.

Phase 1: Version Tracking Infrastructure

Goal: Add _schema_version field to all node instances

Files to Modify:

  • packages/core/src/services/node_service.rs (lines ~150-200, ~300-350)
  • packages/core/src/models/node.rs (if type changes needed)

Implementation Steps:

  1. Add Version to Node Creation in create_node() method:
// In create_node() method
pub async fn create_node(&self, params: CreateNodeParams) -> Result<Node, ServiceError> {
    // ... existing validation ...

    // NEW: Add schema version tracking
    let mut properties = params.properties.unwrap_or_else(|| json!({}));

    // Get current schema version for this node type
    if let Some(schema) = self.get_schema_for_type(&params.node_type).await? {
        if let Some(obj) = properties.as_object_mut() {
            obj.insert(
                "_schema_version".to_string(),
                json!(schema.version)
            );
        }
    } else {
        // No schema defined - default to version 1
        if let Some(obj) = properties.as_object_mut() {
            obj.insert("_schema_version".to_string(), json!(1));
        }
    }

    // ... rest of create logic ...
}
  1. Add Schema Lookup Helper:
// Add new helper method to NodeService
impl NodeService {
    /// Get schema definition for a given node type
    async fn get_schema_for_type(&self, node_type: &str) -> Result<Option<SchemaDefinition>, ServiceError> {
        // Schema lookup convention: id = type_name, node_type = "schema"
        let schema_node = self.get_node(node_type).await?;

        if let Some(node) = schema_node {
            if node.node_type == "schema" {
                let schema: SchemaDefinition = serde_json::from_value(node.properties)?;
                return Ok(Some(schema));
            }
        }

        Ok(None)
    }
}
  1. Backfill Existing Nodes in get_node() method:
// In get_node() method - add backfill logic
pub async fn get_node(&self, id: &str) -> Result<Option<Node>, ServiceError> {
    let mut node = self.db.get_node(id).await?;

    if let Some(ref mut node) = node {
        // NEW: Backfill nodes without version
        if let Some(props) = node.properties.as_object() {
            if !props.contains_key("_schema_version") {
                // No version - backfill with v1
                if let Some(obj) = node.properties.as_object_mut() {
                    obj.insert("_schema_version".to_string(), json!(1));

                    // Persist the backfilled version
                    self.db.update_node(node).await?;
                }
            }
        }
    }

    Ok(node)
}

Testing Requirements:

#[tokio::test]
async fn test_new_nodes_get_schema_version() {
    let service = setup_test_service().await;

    let node = service.create_node(CreateNodeParams {
        node_type: "task".to_string(),
        content: "Test task".to_string(),
        properties: Some(json!({"status": "OPEN"})),
        ..Default::default()
    }).await.unwrap();

    assert!(node.properties["_schema_version"].is_number());
    assert_eq!(node.properties["_schema_version"], 1);
}

#[tokio::test]
async fn test_existing_nodes_backfilled_with_version() {
    let service = setup_test_service().await;

    // Manually create node without version (simulating old data)
    let node_id = service.db.execute(
        "INSERT INTO nodes (id, node_type, content, properties) VALUES (?, ?, ?, ?)",
        params!["test-node", "task", "Old task", json!({"status": "OPEN"})]
    ).await.unwrap();

    // Fetch the node (should trigger backfill)
    let node = service.get_node("test-node").await.unwrap().unwrap();

    // Verify version was backfilled
    assert_eq!(node.properties["_schema_version"], 1);
}

Exit Criteria:

  • All new nodes automatically get _schema_version field
  • Existing nodes get backfilled with version 1 on first access
  • Version number matches current schema version
  • Tests pass
  • No breaking changes to existing functionality

Common Errors:

  • "Cannot insert _schema_version into non-object properties" → Initialize as empty object before inserting
  • "Schema not found for node type" → Default to version 1 if schema lookup fails

Phase 2: SchemaService Core Operations

Goal: Create SchemaService with CRUD operations and protection level enforcement

Files to Create:

  • packages/core/src/models/schema.rs (new file - types)
  • packages/core/src/services/schema_service.rs (new file)
  • packages/core/src/services/mod.rs (add schema_service export)

Implementation Steps:

  1. Define Schema Types (models/schema.rs):
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ProtectionLevel {
    Core,   // Cannot modify/delete
    User,   // Fully modifiable
    System, // Auto-managed, read-only
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaField {
    pub name: String,
    #[serde(rename = "type")]
    pub field_type: String,
    pub protection: ProtectionLevel,

    // Enum-specific
    #[serde(skip_serializing_if = "Option::is_none")]
    pub core_values: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user_values: Option<Vec<String>>,

    pub indexed: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub required: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub extensible: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub default: Option<serde_json::Value>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub item_type: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaDefinition {
    pub is_core: bool,
    pub version: u32,
    pub description: String,
    pub fields: Vec<SchemaField>,
}

impl SchemaDefinition {
    /// Get all enum values (core + user)
    pub fn get_enum_values(&self, field_name: &str) -> Option<Vec<String>> {
        let field = self.fields.iter().find(|f| f.name == field_name)?;

        let mut values = Vec::new();
        if let Some(core_vals) = &field.core_values {
            values.extend(core_vals.clone());
        }
        if let Some(user_vals) = &field.user_values {
            values.extend(user_vals.clone());
        }

        Some(values)
    }

    /// Check if a field can be deleted
    pub fn can_delete_field(&self, field_name: &str) -> bool {
        if let Some(field) = self.fields.iter().find(|f| f.name == field_name) {
            field.protection == ProtectionLevel::User
        } else {
            false
        }
    }
}
  1. Create SchemaService (services/schema_service.rs):
use crate::models::node::Node;
use crate::models::schema::{SchemaDefinition, SchemaField, ProtectionLevel};
use crate::services::node_service::NodeService;
use anyhow::{anyhow, Result};
use serde_json::json;
use std::sync::Arc;

pub struct SchemaService {
    node_service: Arc<NodeService>,
}

impl SchemaService {
    pub fn new(node_service: Arc<NodeService>) -> Self {
        Self { node_service }
    }

    /// Get schema definition by schema ID (convention: id = type_name)
    pub async fn get_schema(&self, schema_id: &str) -> Result<SchemaDefinition> {
        let node = self.node_service
            .get_node(schema_id)
            .await?
            .ok_or_else(|| anyhow!("Schema '{}' not found", schema_id))?;

        if node.node_type != "schema" {
            return Err(anyhow!("Node '{}' is not a schema (type: {})", schema_id, node.node_type));
        }

        let schema: SchemaDefinition = serde_json::from_value(node.properties)?;
        Ok(schema)
    }

    /// Add a new field to a schema (only user-protected fields allowed)
    pub async fn add_field(&self, schema_id: &str, field: SchemaField) -> Result<()> {
        // Protection: Only user fields can be added
        if field.protection != ProtectionLevel::User {
            return Err(anyhow!(
                "Can only add user-protected fields. Field '{}' has protection: {:?}",
                field.name, field.protection
            ));
        }

        let mut schema = self.get_schema(schema_id).await?;

        // Check if field already exists
        if schema.fields.iter().any(|f| f.name == field.name) {
            return Err(anyhow!("Field '{}' already exists in schema '{}'", field.name, schema_id));
        }

        // Add field and bump version
        schema.fields.push(field);
        schema.version += 1;

        // Update schema node
        self.update_schema(schema_id, schema).await?;

        Ok(())
    }

    /// Remove a field from schema (only user-protected fields can be removed)
    pub async fn remove_field(&self, schema_id: &str, field_name: &str) -> Result<()> {
        let mut schema = self.get_schema(schema_id).await?;

        // Find the field
        let field_idx = schema.fields.iter()
            .position(|f| f.name == field_name)
            .ok_or_else(|| anyhow!("Field '{}' not found in schema '{}'", field_name, schema_id))?;

        let field = &schema.fields[field_idx];

        // Protection: Only user fields can be removed
        if field.protection != ProtectionLevel::User {
            return Err(anyhow!(
                "Cannot remove field '{}' with protection level {:?}. Only user fields can be removed.",
                field_name, field.protection
            ));
        }

        // Remove field and bump version
        schema.fields.remove(field_idx);
        schema.version += 1;

        // Update schema node
        self.update_schema(schema_id, schema).await?;

        Ok(())
    }

    /// Extend an enum field with a new value (adds to user_values only)
    pub async fn extend_enum_field(&self, schema_id: &str, field_name: &str, new_value: String) -> Result<()> {
        let mut schema = self.get_schema(schema_id).await?;

        // Find the field
        let field = schema.fields.iter_mut()
            .find(|f| f.name == field_name)
            .ok_or_else(|| anyhow!("Field '{}' not found in schema '{}'", field_name, schema_id))?;

        // Verify it's an enum field
        if field.field_type != "enum" {
            return Err(anyhow!("Field '{}' is not an enum (type: {})", field_name, field.field_type));
        }

        // Verify it's extensible
        if !field.extensible.unwrap_or(false) {
            return Err(anyhow!("Enum field '{}' is not extensible", field_name));
        }

        // Check if value already exists (in core or user values)
        let all_values = schema.get_enum_values(field_name).unwrap_or_default();
        if all_values.contains(&new_value) {
            return Err(anyhow!("Value '{}' already exists in enum '{}'", new_value, field_name));
        }

        // Add to user_values
        field.user_values.get_or_insert_with(Vec::new).push(new_value);
        schema.version += 1;

        // Update schema node
        self.update_schema(schema_id, schema).await?;

        Ok(())
    }

    /// Remove a value from enum field (only from user_values, cannot remove core_values)
    pub async fn remove_enum_value(&self, schema_id: &str, field_name: &str, value: &str) -> Result<()> {
        let mut schema = self.get_schema(schema_id).await?;

        // Find the field
        let field = schema.fields.iter_mut()
            .find(|f| f.name == field_name)
            .ok_or_else(|| anyhow!("Field '{}' not found in schema '{}'", field_name, schema_id))?;

        // Check if value is in core_values (cannot remove)
        if let Some(core_vals) = &field.core_values {
            if core_vals.contains(&value.to_string()) {
                return Err(anyhow!(
                    "Cannot remove core value '{}' from enum '{}'. Only user values can be removed.",
                    value, field_name
                ));
            }
        }

        // Remove from user_values
        if let Some(user_vals) = &mut field.user_values {
            if let Some(idx) = user_vals.iter().position(|v| v == value) {
                user_vals.remove(idx);
                schema.version += 1;

                // Update schema node
                self.update_schema(schema_id, schema).await?;
                return Ok(());
            }
        }

        Err(anyhow!("Value '{}' not found in user values of enum '{}'", value, field_name))
    }

    /// Update schema node with new definition
    async fn update_schema(&self, schema_id: &str, schema: SchemaDefinition) -> Result<()> {
        let properties = serde_json::to_value(&schema)?;

        self.node_service.update_node(UpdateNodeParams {
            id: schema_id.to_string(),
            properties: Some(properties),
            ..Default::default()
        }).await?;

        Ok(())
    }
}

Testing Requirements: See Phase 2 tests in commit template below

Exit Criteria:

  • SchemaService can get/add/remove fields
  • Protection levels enforced (cannot modify core/system fields)
  • Enum values can be extended (user_values only)
  • Core enum values cannot be removed
  • Schema version increments on changes
  • All tests pass

Phase 3: Migration Registry System

Goal: Build migration infrastructure for schema transform functions

Files to Create:

  • packages/core/src/services/migration_registry.rs (new file)
  • packages/core/src/services/migrations/task.rs (example migrations)

Implementation: See full code in commit template section below

Exit Criteria:

  • Migrations can be registered
  • Migration transforms apply correctly
  • Migration chains work (v1→v2→v3)
  • Node version updates after migration
  • Tests pass

Phase 4: Lazy Migration Integration

Goal: Auto-upgrade nodes on access

Files to Modify:

  • packages/core/src/services/node_service.rs

Implementation: Hook migrations into get_node() and query_nodes() - see commit template

Exit Criteria:

  • get_node() applies lazy migration
  • query_nodes() migrates all results
  • Migrations only applied when needed
  • Migrated nodes persisted to database
  • No performance degradation
  • Tests pass

Phase 5: MCP Generic Validation

Goal: Protect schemas via existing update_node

Files to Modify:

  • packages/core/src/mcp/handlers/nodes.rs

Exit Criteria:

  • update_node validates schema changes
  • Core field deletion rejected
  • Core field type changes rejected
  • Core enum values protected
  • User field modifications allowed
  • Tool description updated
  • Tests pass

Phase 6: MCP Specialized Schema Tools

Goal: Better UX for schema operations via MCP

Files to Create:

  • packages/core/src/mcp/handlers/schema.rs

Tools to Add:

  • add_schema_field - Add new field with protection enforcement
  • remove_schema_field - Remove user fields only
  • extend_schema_enum - Add to user_values
  • remove_schema_enum_value - Remove from user_values
  • get_schema_definition - Retrieve parsed schema

Exit Criteria:

  • All 5 specialized tools implemented
  • Tools registered in MCP
  • Tool descriptions clear and complete
  • Error messages actionable
  • Tests pass
  • MCP integration tests work

Phase 7: Frontend SchemaService Wrapper

Goal: TypeScript wrapper for future UI

Files to Create:

  • packages/desktop-app/src/lib/services/schemaService.ts
  • packages/desktop-app/src/lib/types/schema.ts

Exit Criteria:


Key Enhancements

1. Field Protection Levels

  • core: Cannot be deleted, type cannot change (UI components depend on these)
  • user: User-added fields, fully modifiable/deletable
  • system: Auto-managed fields, read-only (future use)

2. Enum Field Support

  • core_values: Protected enum values (cannot be removed)
  • user_values: User-extensible enum values (can be added/removed)
  • Validation against allowed values
  • Auto-complete suggestions from schema

3. Lazy Migration Strategy

  • Version tracking: Each node tracks _schema_version in properties
  • On-access migration: Nodes upgrade when read, not bulk operation
  • Desktop-safe: No coordinated migrations across installations
  • Migration transforms: Pure functions (v1→v2, v2→v3, etc.)
  • Backward compatible: Old nodes continue working

4. Schema Field Definition

export interface SchemaField {
  name: string;
  type: string;
  protection: 'core' | 'user' | 'system';

  core_values?: string[];
  user_values?: string[];

  indexed: boolean;
  required?: boolean;
  extensible?: boolean;
  default?: unknown;
  description?: string;
  item_type?: string;
}

export interface SchemaDefinition {
  is_core: boolean;
  version: number;
  description: string;
  fields: SchemaField[];
}

Acceptance Criteria

Phase 1: Version Tracking ✅

  • New nodes get _schema_version automatically
  • Existing nodes backfilled with version 1
  • Version persists in properties JSON
  • Tests pass

Phases 2-7

See each phase section above for detailed acceptance criteria.


Sub-Agent Delegation Guide

When to Use Sub-Agents

Phase Recommended Sub-Agent Rationale
1 - Version Tracking general-purpose Straightforward NodeService modification
2 - SchemaService senior-architect-reviewer Complex service with business logic
3 - Migration Registry senior-architect-reviewer Critical architecture component
4 - Lazy Integration senior-architect-reviewer Complex integration, performance critical
5 - MCP Validation ai-ml-engineer MCP expertise required
6 - MCP Tools ai-ml-engineer MCP tool design and registration
7 - Frontend frontend-architect TypeScript/Svelte expertise

Sub-Agent Commissioning Template

CRITICAL SUB-AGENT INSTRUCTIONS:
- Main agent has completed setup (no startup sequence needed)
- You are implementing Phase N/7 of issue #106
- Focus ONLY on [specific deliverable]
- DO NOT commit changes - return control to main agent when done
- DO NOT run project management commands (gh:status, gh:pr, etc.)
- Continue established patterns from previous phases

CONTEXT FROM PREVIOUS PHASE:
[Key decisions, file locations, patterns established]

YOUR DELIVERABLE:
Phase N: [Phase Name]
- Create/modify: [specific files]
- Implement: [specific functions/features]
- Test: [specific test cases]
- Document: [inline comments, function docs]

EXIT CRITERIA:
- [Specific checklist from phase spec]
- Code compiles without errors
- Tests pass
- No TODO comments left in code

Return control to main agent with summary of what was implemented.

Commit Message Templates

Standard Template

<type>: <description> (#106 - Phase N/7)

## Summary
[1-2 sentences: what this commit accomplishes]

## Changes
- [File 1]: [what changed]
- [File 2]: [what changed]
- [Tests]: [test coverage added]

## Architecture Decisions
[Important choices made and why]

## Testing
- [x] [Test scenario 1 - passing]
- [x] [Test scenario 2 - passing]

## Verification Steps
```bash
# Manual verification
bun run test:db -- schema_service_test

Next Steps for AI Agent

Phase N+1: [Next Phase Name]

Context needed:

  • [What the next agent should read first]
  • [Key patterns established in this phase]

Implementation tasks:

  • [Specific file to create/modify with path]
  • [Key functions/features to implement]

See Phase N+1 section in #106 for detailed spec.

Related

🤖 Generated with Claude Code
Co-Authored-By: Claude noreply@anthropic.com


---

## Common Pitfalls & Solutions

### 1. Forgetting to Increment Schema Version
**Problem**: Schema changes don't increment version, causing lazy migration to not trigger.

**Solution**: Every schema modification method must increment `schema.version`:
```rust
// ✅ GOOD - Always increment
schema.fields.push(new_field);
schema.version += 1;
self.update_schema(schema_id, schema).await?;

2. Migration Applied Multiple Times

Problem: Migration runs every time node is accessed instead of once.

Solution: Check version before applying migration.

3. Circular Dependency (NodeService ↔ SchemaService)

Solution: SchemaService wraps NodeService (one direction only).

4. Not Persisting Migrated Nodes

Solution: Always persist after migration.

5. Not Testing Protection Enforcement

Solution: Always test negative cases.


Testing Strategy

Phase 1: Version tracking tests
Phase 2: SchemaService CRUD + protection tests
Phase 3: Migration registry tests
Phase 4: Lazy integration tests
Phase 5: MCP validation tests
Phase 6: MCP tool tests
Phase 7: Frontend service tests


Related Issues


For Junior Engineers

Getting Started

  1. Read this issue completely - All implementation details are here
  2. Start with Phase 1 - Version tracking is the foundation
  3. One phase at a time - Each phase is independently testable
  4. Use sub-agents for complex phases - Delegate to specialists
  5. Follow commit templates - Handoff documentation for next session

Key Architecture Documents

  • Database Schema: docs/architecture/business-logic/database-schema.md
  • Storage Architecture: docs/architecture/data/storage-architecture.md
  • Validation System: docs/architecture/components/validation-system.md

Activity

  1. malibio commented on Oct 22, 2025

    @malibio
    CollaboratorAuthor

    Integration with NodeOperations Layer (#332)

    Update (2025-01-22): This issue will be implemented after #332 (NodeOperations Business Logic Layer) is complete.

    How Dynamic Entity Types Work with NodeOperations

    1. User defines entity via MCP:

      await mcp.call("define_entity_type", {
        type_name: "customer",
        schema: { /* field definitions */ }
      });
    2. System auto-generates MCP tools:

      • create_customer → calls NodeOperations.create_node("customer", ...)
      • find_customers → calls NodeOperations.query_nodes(type: "customer", ...)
      • update_customer → calls NodeOperations.update_node(...)
    3. Business rules still enforced:

      • All container/sibling/parent rules apply to custom types
      • NodeOperations validates structure regardless of entity type
      • Schema validation happens at MCP tool layer

    Dependencies

  2. self-assigned this
    on Nov 2, 2025
  3. changed the title [-]Implement schema management for dynamic user-defined entities[/-] [+]Implement Schema Management Service with Migration Infrastructure[/+] on Nov 2, 2025
  4. 71 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions