Skip to content

Chapter 10 - Automatic Persistence

Chapter 10: The Root Node and Persistence#

One of Jac's most revolutionary features is automatic persistence through the root node. Unlike traditional applications that require explicit database operations, Jac programs naturally persist state between executions. This chapter explores how the root node enables scale-agnostic programming, where the same code works for single-user scripts and multi-user applications.

10.1 Understanding the Root Node#

Global Accessibility via root Keyword#

The root keyword provides global access to a special persistent node that serves as the anchor for your application's data:

// root is available everywhere - no imports needed
with entry {
    print(f"Root node: {root}");
    print(f"Type: {type(root).__name__}");

    // root is always the same node within a user context
    let id1 = id(root);
    do_something();
    let id2 = id(root);
    assert id1 == id2;  // Always true
}

can do_something() {
    // root accessible in any function
    root ++> node { has data: str = "test"; };
}

walker Explorer {
    can explore with entry {
        // root accessible in walkers
        print(f"Starting from: {root}");
        visit root;
    }
}

node CustomNode {
    can check_root with entry {
        // root accessible in node abilities
        print(f"Root from node: {root}");
    }
}

The root node is special: - Always Available: No declaration or initialization needed - Globally Accessible: Available in any context without passing - Type-Safe: It's a real node with all node capabilities - User-Specific: Each user gets their own isolated root

Automatic Persistence Model#

Everything connected to root persists automatically:

// First run - create data
with entry {
    print("=== First Run - Creating Data ===");

    // Data connected to root persists
    let user_profile = root ++> node UserProfile {
        has username: str = "alice";
        has created_at: str = "2024-01-15";
        has login_count: int = 1;
    };

    print(f"Created profile: {user_profile.username}");
}

// Second run - data still exists!
with entry {
    print("=== Second Run - Data Persists ===");

    // Find existing data
    let profiles = root[-->:UserProfile:];
    if profiles {
        let profile = profiles[0];
        print(f"Found profile: {profile.username}");
        print(f"Previous logins: {profile.login_count}");

        // Update persistent data
        profile.login_count += 1;
        print(f"Updated logins: {profile.login_count}");
    }
}

// Third run - updates persist too
with entry {
    print("=== Third Run - Updates Persist ===");

    let profile = root[-->:UserProfile:][0];
    print(f"Login count is now: {profile.login_count}");  // Shows 3
}

Reachability-Based Persistence#

Nodes persist based on reachability from root:

node Document {
    has title: str;
    has content: str;
    has created: str;
}

node Tag {
    has name: str;
    has color: str = "#0000FF";
}

with entry {
    // Connected to root = persistent
    let doc1 = root ++> Document(
        title="My First Document",
        content="This will persist",
        created=now()
    );

    // Connected to persistent node = also persistent
    let tag1 = doc1 ++> Tag(name="important");

    // NOT connected to root = temporary
    let doc2 = Document(
        title="Temporary Document",
        content="This will NOT persist",
        created=now()
    );

    // Connecting later makes it persistent
    root ++> doc2;  // Now doc2 will persist

    // Disconnecting makes it non-persistent
    del root --> doc1;  // doc1 and tag1 no longer persist
}
graph TD
    R[root<br/>≪always persistent≫]
    D1[Document 1<br/>≪persistent≫]
    D2[Document 2<br/>≪persistent≫]
    D3[Document 3<br/>≪temporary≫]
    T1[Tag 1<br/>≪persistent≫]
    T2[Tag 2<br/>≪temporary≫]

    R --> D1
    R --> D2
    D1 --> T1
    D3 --> T2

    style R fill:#4caf50,color:white
    style D1 fill:#c8e6c9
    style D2 fill:#c8e6c9
    style D3 fill:#ffcdd2
    style T1 fill:#c8e6c9
    style T2 fill:#ffcdd2

10.2 Building Persistent Applications#

Connecting to Root for Persistence#

Here's how to design applications with automatic persistence:

// Application data model
node AppData {
    has version: str = "1.0.0";
    has settings: dict = {};
    has initialized: bool = false;
}

node User {
    has id: str;
    has email: str;
    has preferences: dict = {};
    has created_at: str;
}

node Session {
    has token: str;
    has user_id: str;
    has expires_at: str;
    has active: bool = true;
}

// Initialize or get existing app data
can get_or_create_app_data() -> AppData {
    let app_data_nodes = root[-->:AppData:];

    if not app_data_nodes {
        print("First run - initializing app data");
        return root ++> AppData(
            initialized=true,
            settings={
                "theme": "light",
                "language": "en",
                "debug": false
            }
        );
    }

    return app_data_nodes[0];
}

// User management with persistence
can create_user(email: str) -> User? {
    let app = get_or_create_app_data();

    // Check if user exists
    let existing = app[-->:User:].filter(
        lambda u: User -> bool : u.email == email
    );

    if existing {
        print(f"User {email} already exists");
        return None;
    }

    // Create persistent user
    let user = app ++> User(
        id=generate_id(),
        email=email,
        created_at=now()
    );

    print(f"Created user: {email}");
    return user;
}

// Session management
can create_session(user: User) -> Session {
    import:py from datetime import datetime, timedelta;

    // Sessions connected to user (persistent)
    let session = user ++> Session(
        token=generate_token(),
        user_id=user.id,
        expires_at=(datetime.now() + timedelta(hours=24)).isoformat()
    );

    return session;
}

// Example usage
with entry {
    let app = get_or_create_app_data();
    print(f"App version: {app.version}");

    // Create or get user
    let email = "alice@example.com";
    let user = create_user(email);

    if user {
        let session = create_session(user);
        print(f"Session created: {session.token[:8]}...");
    } else {
        // User already exists, find them
        let user = app[-->:User:].filter(
            lambda u: User -> bool : u.email == email
        )[0];
        print(f"Welcome back, {user.email}!");
        print(f"Account created: {user.created_at}");
    }
}

Managing Ephemeral vs Persistent State#

Not everything should persist. Here's how to manage both:

node PersistentCache {
    has data: dict = {};
    has updated_at: str;
}

node EphemeralCache {
    has data: dict = {};
    has created_at: str;
}

walker CacheManager {
    has operation: str;
    has key: str;
    has value: any? = None;

    can manage with entry {
        // Get or create persistent cache
        let p_cache = root[-->:PersistentCache:][0] if root[-->:PersistentCache:]
                     else root ++> PersistentCache(updated_at=now());

        // Ephemeral cache is not connected to root
        let e_cache = EphemeralCache(created_at=now());

        if self.operation == "store" {
            // Store in both caches
            p_cache.data[self.key] = self.value;
            p_cache.updated_at = now();
            e_cache.data[self.key] = self.value;

            print(f"Stored {self.key} in both caches");

        } elif self.operation == "get" {
            // Try ephemeral first (faster)
            if self.key in e_cache.data {
                print(f"Found {self.key} in ephemeral cache");
                report e_cache.data[self.key];
            } elif self.key in p_cache.data {
                print(f"Found {self.key} in persistent cache");
                report p_cache.data[self.key];
            } else {
                print(f"Key {self.key} not found");
                report None;
            }
        }
    }
}

// Hybrid approach for performance
node FastStore {
    has persistent_data: dict = {};    // Important data
    has memory_cache: dict = {};      // Temporary cache
    has stats: dict = {               // Temporary stats
        "hits": 0,
        "misses": 0
    };

    can get(key: str) -> any? {
        // Check memory first
        if key in self.memory_cache {
            self.stats["hits"] += 1;
            return self.memory_cache[key];
        }

        // Check persistent
        if key in self.persistent_data {
            self.stats["misses"] += 1;
            // Populate memory cache
            self.memory_cache[key] = self.persistent_data[key];
            return self.persistent_data[key];
        }

        return None;
    }

    can store(key: str, value: any, persist: bool = true) {
        self.memory_cache[key] = value;

        if persist {
            self.persistent_data[key] = value;
        }
    }
}

Database-Free Data Persistence#

Jac eliminates the need for separate databases in many applications:

// Traditional approach requires database setup
// Python with SQLAlchemy:
# from sqlalchemy import create_engine, Column, String, Integer
# from sqlalchemy.ext.declarative import declarative_base
#
# Base = declarative_base()
# engine = create_engine('sqlite:///app.db')
#
# class Task(Base):
#     __tablename__ = 'tasks'
#     id = Column(Integer, primary_key=True)
#     title = Column(String)
#     completed = Column(Boolean)
#
# Base.metadata.create_all(engine)
# session = Session(engine)
# # ... lots more boilerplate ...

// Jac approach - just connect to root!
node Task {
    has id: str;
    has title: str;
    has completed: bool = false;
    has created_at: str;
    has completed_at: str? = None;
}

node TaskList {
    has name: str;
    has created_at: str;
}

// Complete task management with zero database code
walker TaskManager {
    has command: str;
    has title: str = "";
    has list_name: str = "default";
    has task_id: str = "";

    can execute with entry {
        // Get or create task list
        let lists = root[-->:TaskList:(?.name == self.list_name):];
        let task_list = lists[0] if lists else root ++> TaskList(
            name=self.list_name,
            created_at=now()
        );

        match self.command {
            case "add": self.add_task(task_list);
            case "complete": self.complete_task(task_list);
            case "list": self.list_tasks(task_list);
            case "stats": self.show_stats(task_list);
        }
    }

    can add_task(task_list: TaskList) {
        let task = task_list ++> Task(
            id=generate_id(),
            title=self.title,
            created_at=now()
        );

        print(f"Added task: {task.title} (ID: {task.id[:8]})");
    }

    can complete_task(task_list: TaskList) {
        let tasks = task_list[-->:Task:(?.id.startswith(self.task_id)):];

        if tasks {
            let task = tasks[0];
            task.completed = true;
            task.completed_at = now();
            print(f"Completed: {task.title}");
        } else {
            print(f"Task {self.task_id} not found");
        }
    }

    can list_tasks(task_list: TaskList) {
        let tasks = task_list[-->:Task:];
        print(f"\n=== {task_list.name} Tasks ===");

        for task in tasks {
            let status = "âś“" if task.completed else "â—‹";
            print(f"{status} [{task.id[:8]}] {task.title}");
        }

        let completed = tasks.filter(lambda t: Task -> bool : t.completed);
        print(f"\nTotal: {len(tasks)} | Completed: {len(completed)}");
    }

    can show_stats(task_list: TaskList) {
        let tasks = task_list[-->:Task:];
        let completed = tasks.filter(lambda t: Task -> bool : t.completed);

        print(f"\n=== Task Statistics ===");
        print(f"List: {task_list.name}");
        print(f"Total tasks: {len(tasks)}");
        print(f"Completed: {len(completed)}");
        print(f"Pending: {len(tasks) - len(completed)}");

        if completed {
            // Calculate average completion time
            import:py from datetime import datetime;
            total_time = 0;

            for task in completed {
                created = datetime.fromisoformat(task.created_at);
                completed = datetime.fromisoformat(task.completed_at);
                total_time += (completed - created).total_seconds();
            }

            avg_hours = (total_time / len(completed)) / 3600;
            print(f"Avg completion time: {avg_hours:.1f} hours");
        }
    }
}

// Usage - all data persists automatically!
with entry {
    import:py sys;

    if len(sys.argv) < 2 {
        print("Usage: jac run tasks.jac <command> [args]");
        print("Commands:");
        print("  add <title> - Add a new task");
        print("  complete <id> - Mark task as complete");
        print("  list - Show all tasks");
        print("  stats - Show statistics");
        return;
    }

    let command = sys.argv[1];
    let manager = TaskManager(command=command);

    if command == "add" and len(sys.argv) > 2 {
        manager.title = " ".join(sys.argv[2:]);
    } elif command == "complete" and len(sys.argv) > 2 {
        manager.task_id = sys.argv[2];
    }

    spawn manager on root;
}

Advanced Persistence Patterns#

Versioned Data#

node VersionedDocument {
    has id: str;
    has content: str;
    has version: int = 1;
    has created_at: str;
    has modified_at: str;
}

node DocumentVersion {
    has version: int;
    has content: str;
    has modified_at: str;
    has modified_by: str;
}

walker DocumentEditor {
    has doc_id: str;
    has new_content: str;
    has user: str;

    can edit with entry {
        // Find document
        let docs = root[-->:VersionedDocument:(?.id == self.doc_id):];
        if not docs {
            print(f"Document {self.doc_id} not found");
            return;
        }

        let doc = docs[0];

        // Save current version
        doc ++> DocumentVersion(
            version=doc.version,
            content=doc.content,
            modified_at=doc.modified_at,
            modified_by=self.user
        );

        // Update document
        doc.content = self.new_content;
        doc.version += 1;
        doc.modified_at = now();

        print(f"Document updated to version {doc.version}");
    }

    can get_history with entry {
        let docs = root[-->:VersionedDocument:(?.id == self.doc_id):];
        if not docs {
            return;
        }

        let doc = docs[0];
        let versions = doc[-->:DocumentVersion:];

        print(f"\n=== History for {doc.id} ===");
        print(f"Current version: {doc.version}");

        for v in versions.sorted(key=lambda x: x.version, reverse=true) {
            print(f"\nVersion {v.version}:");
            print(f"  Modified: {v.modified_at}");
            print(f"  By: {v.modified_by}");
            print(f"  Content: {v.content[:50]}...");
        }
    }
}

Lazy Loading Pattern#

node DataContainer {
    has id: str;
    has metadata: dict;
    has data_loaded: bool = false;
}

node HeavyData {
    has payload: list;
    has size_mb: float;
}

walker DataLoader {
    has container_id: str;
    has operation: str;

    can operate with DataContainer entry {
        if self.operation == "get_metadata" {
            // Just return metadata without loading heavy data
            report here.metadata;

        } elif self.operation == "load_full" {
            if not here.data_loaded {
                // Load heavy data only when needed
                self.load_heavy_data(here);
            }

            let heavy = here[-->:HeavyData:][0];
            report {
                "metadata": here.metadata,
                "data": heavy.payload,
                "size": heavy.size_mb
            };
        }
    }

    can load_heavy_data(container: DataContainer) {
        print(f"Loading heavy data for {container.id}...");

        // Simulate loading large data
        import:py time;
        time.sleep(1);

        container ++> HeavyData(
            payload=list(range(1000000)),
            size_mb=7.6
        );

        container.data_loaded = true;
    }
}

Garbage Collection Pattern#

node CachedItem {
    has key: str;
    has value: any;
    has created_at: str;
    has last_accessed: str;
    has ttl_hours: int = 24;
}

walker CacheCleanup {
    has cleaned_count: int = 0;
    has checked_count: int = 0;

    can cleanup with CachedItem entry {
        import:py from datetime import datetime, timedelta;

        self.checked_count += 1;

        last_access = datetime.fromisoformat(here.last_accessed);
        age = datetime.now() - last_access;

        if age > timedelta(hours=here.ttl_hours) {
            print(f"Removing expired cache item: {here.key}");

            // Disconnect from root to remove persistence
            for edge in here[<--] {
                del edge;
            }

            self.cleaned_count += 1;
        }

        visit [-->];
    }

    can report with exit {
        print(f"\nCache cleanup complete:");
        print(f"  Checked: {self.checked_count} items");
        print(f"  Cleaned: {self.cleaned_count} items");
    }
}

// Run periodic cleanup
with entry:cleanup {
    print("Running cache cleanup...");
    spawn CacheCleanup() on root;
}

Performance Considerations#

While persistence is automatic, consider these patterns for optimization:

// Indexing pattern for fast lookups
node IndexedCollection {
    has name: str;
    has indices: dict = {};

    can add_item(item: dict) {
        // Store item
        let item_node = self ++> node DataItem {
            has data: dict;
        }(data=item);

        // Update indices
        for key, value in item.items() {
            if key not in self.indices {
                self.indices[key] = {};
            }

            if value not in self.indices[key] {
                self.indices[key][value] = [];
            }

            self.indices[key][value].append(item_node);
        }
    }

    can find_by(key: str, value: any) -> list {
        if key in self.indices and value in self.indices[key] {
            return self.indices[key][value];
        }
        return [];
    }
}

// Pagination pattern for large collections
walker PaginatedQuery {
    has page: int = 1;
    has page_size: int = 20;
    has filters: dict = {};
    has total_count: int = 0;
    has results: list = [];

    can query with entry {
        // Get all matching items
        let all_items = root[-->:DataItem:];

        // Apply filters
        let filtered = all_items;
        for key, value in self.filters.items() {
            filtered = filtered.filter(
                lambda item: DataItem -> bool : item.data.get(key) == value
            );
        }

        self.total_count = len(filtered);

        // Paginate
        let start = (self.page - 1) * self.page_size;
        let end = start + self.page_size;
        self.results = filtered[start:end];

        report {
            "page": self.page,
            "page_size": self.page_size,
            "total": self.total_count,
            "pages": (self.total_count + self.page_size - 1) // self.page_size,
            "data": self.results
        };
    }
}

Summary#

In this chapter, we've explored Jac's revolutionary persistence model:

  • The Root Node: A globally accessible anchor for persistent data
  • Automatic Persistence: No database required—just connect to root
  • Reachability Model: Data persists based on graph connectivity
  • Zero Configuration: No schema definitions, migrations, or connection strings
  • Performance Patterns: Indexing, lazy loading, and cleanup strategies

This persistence model eliminates entire categories of boilerplate code. You focus on your domain logic while Jac handles data persistence automatically. The same patterns that work for a simple script scale to multi-user applications—which we'll explore in the next chapter.