Repository navigation
Implement Schema Management Service with Migration Infrastructure #106
Description
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
- Architecture Overview
- 7-Phase Implementation Plan
- Key Enhancements
- Acceptance Criteria
- Sub-Agent Delegation Guide
- Commit Message Templates
- Common Pitfalls & Solutions
- Testing Strategy
- 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:
- 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(¶ms.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 ...
}- 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)
}
}- 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_versionfield - 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:
- 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
}
}
}- 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_nodevalidates 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 enforcementremove_schema_field- Remove user fields onlyextend_schema_enum- Add to user_valuesremove_schema_enum_value- Remove from user_valuesget_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.tspackages/desktop-app/src/lib/types/schema.ts
Exit Criteria:
- TypeScript SchemaService created
- Types mirror Rust API
- Helper methods implemented
- Tests pass
- Ready for UI (SchemaNodeViewer for Schema Editing (DEFERRED - Backend Complete) #195)
Key Enhancements
1. Field Protection Levels
core: Cannot be deleted, type cannot change (UI components depend on these)user: User-added fields, fully modifiable/deletablesystem: 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_versionin 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_versionautomatically - 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
- Issue: Implement Schema Management Service with Migration Infrastructure #106 (Schema Management)
- Depends on: Phase N-1
- Blocks: Phase N+1
🤖 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
- SchemaNodeViewer for Schema Editing (DEFERRED - Backend Complete) #195 - SchemaNodeViewer UI (DEFERRED - backend complete via this issue)
- Update Task Schema with Protection Levels and Enum Support #191 - Update Task Schema (depends on Phase 2 minimum)
- Implement Schema-Driven Property UI for Node Viewers #193 - Schema-Driven Property UI (depends on Phase 2 minimum)
For Junior Engineers
Getting Started
- Read this issue completely - All implementation details are here
- Start with Phase 1 - Version tracking is the foundation
- One phase at a time - Each phase is independently testable
- Use sub-agents for complex phases - Delegate to specialists
- 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
Update schema documentation with protection levels and enum support
Add comprehensive field types reference to schema documentation
Fix: Shift+Enter inline formatting splitting (Issue #185) (#188)
malibio commentedon Oct 22, 2025
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
User defines entity via MCP:
System auto-generates MCP tools:
create_customer→ callsNodeOperations.create_node("customer", ...)find_customers→ callsNodeOperations.query_nodes(type: "customer", ...)update_customer→ callsNodeOperations.update_node(...)Business rules still enforced:
Dependencies
Address code review recommendations for NodeOperations (#335)
Address code review recommendations for NodeOperations (#335)
Create NodeOperations Business Logic Layer (#332) (#335)
[-]Implement schema management for dynamic user-defined entities[/-][+]Implement Schema Management Service with Migration Infrastructure[/+]docs: Add comprehensive schema management implementation guide
71 remaining items
Fix: Shift+Enter inline formatting splitting (Issue #185) (#188)
Create NodeOperations Business Logic Layer (#332) (#335)
feat(schema): Phase 1 - Add version tracking infrastructure (#106)
feat: Implement Phase 2 SchemaService with CRUD operations (#106 - Ph…
feat: Implement Phase 3 Migration Registry with lazy upgrade system (#…
feat: Implement Phase 4 Lazy Migration Integration (#106 - Phase 4/7)
feat: Implement Phase 5 MCP Generic Validation (#106 - Phase 5/7)
fix: Resolve rebase conflicts with OCC changes from main (#106)
feat: Implement Phase 6 MCP Specialized Schema Tools (#106 - Phase 6/7)
fix: Fix schema handler test failures (copy-paste error)
feat: Implement Phase 7 Frontend SchemaService Wrapper (#106 - Phase …
refactor: Address pragmatic code review recommendations (#106)
Merge pull request: Schema Management System (#106)
Implement Missing Tauri Commands for Schema Operations (#389)
feat: Implement schema-driven property UI for node viewers (#193)