# How Nodeterm Handles Project Serialization and Deserialization: A Deep Dive into the Save System

> Discover how Nodeterm handles project serialization and deserialization with a unique two-layer persistence architecture for crash-safe, cross-platform project files. Learn more today.

- Repository: [eneskirca/nodeterm](https://github.com/eneskirca/nodeterm)
- Tags: deep-dive
- Published: 2026-08-26

---

**Nodeterm uses a two-layer persistence architecture where the renderer extracts a pure state snapshot and the core writes it atomically to disk, ensuring crash-safe, cross-platform project files.**

Nodeterm treats every project as a self-contained canvas that can be saved, reopened, or moved between machines. The architecture separates concerns between the **renderer process** (which manages the live React Flow UI state) and the **core process** (which handles durable disk I/O and schema migrations). This article examines the complete **project serialization and deserialization** pipeline implemented in the `eneskirca/nodeterm` repository.

## The Two-Layer Architecture

The serialization pipeline is divided into two complementary layers that communicate through well-defined interfaces.

| Layer | Responsibility | Main Source File |
|------|----------------|------------------|
| **Renderer state** | Holds the live React Flow representation of nodes, viewport, and UI-only flags. Extracts a pure snapshot when the user switches projects or quits. | [`src/renderer/state/workspace.ts`](https://github.com/eneskirca/nodeterm/blob/main/src/renderer/state/workspace.ts) |
| **Core persistence** | Writes the snapshot to disk as a project file ([`.nodeterm/project.json`](https://github.com/eneskirca/nodeterm/blob/main/.nodeterm/project.json)) and reads it back, rebuilding in-memory data structures. Uses atomic helpers to guarantee durability. | [`src/core/workspace-store.ts`](https://github.com/eneskirca/nodeterm/blob/main/src/core/workspace-store.ts) and atomic write helpers in [`src/core/fs-atomic.ts`](https://github.com/eneskirca/nodeterm/blob/main/src/core/fs-atomic.ts) |

## Serializing Projects

When the user selects a different project or the app closes, the renderer initiates a save operation that strips transient data and delegates durable storage to the core.

### Preparing Renderer State for Persistence

The `commitActiveToStore()` function in the renderer orchestrates the extraction of serializable state.

1.  It accesses `workspace.state.nodes` (the React Flow node array) and passes it to **`nodeStatesToFlow()`** – a pure function that drops every transient field (`initialCommand`, `respawnNonce`, `remote`, etc.) and converts mutable `NodeState` objects into a stable shape.
2.  It appends project-wide metadata including `viewport`, `defaultAccountId`, `collapsedItems`, and the optional `project.id`.
3.  It returns a plain JSON object matching the **schema v3** (`ProjectFileV3` type) before handing it to the core store.

```typescript
// src/renderer/state/workspace.ts – simplified
function commitActiveToStore() {
  const raw = nodeStatesToFlow(workspace.state.nodes);
  const project = {
    ...raw,
    viewport: workspace.state.viewport,
    collapsedItems: settings.sidebarCollapsedItems,
    // …other meta fields
  };
  workspaceStore.save(project);
}

```

### Atomic Cross-Platform Writes

The `workspaceStore.save()` method in [`src/core/workspace-store.ts`](https://github.com/eneskirca/nodeterm/blob/main/src/core/workspace-store.ts) handles durable persistence. It serializes the object with `JSON.stringify(project, null, 2)` and calls **`writeFileAtomic()`** from [`src/core/fs-atomic.ts`](https://github.com/eneskirca/nodeterm/blob/main/src/core/fs-atomic.ts). This helper writes to a temporary file first, then performs an atomic rename operation. This pattern prevents data corruption from crashes, concurrent saves, and Windows-specific "file-in-use" (`EPERM`) errors.

```typescript
// src/core/workspace-store.ts (excerpt)
async function save(project: ProjectFileV3) {
  const content = JSON.stringify(project, null, 2);
  await writeFileAtomic(projectPath(project.id), content);
}

```

## Deserializing Projects

Loading a project reverses the pipeline: the core reads the file, migrates legacy formats if necessary, and the renderer reconstructs the live UI state.

### Reading and Migrating Legacy Formats

When opening a project via "Open folder", "Open recent", or app startup, the core store executes three steps:

1.  Reads the JSON file using `readFileAtomic`.
2.  Parses the content and runs **version migration** via `migrateV2toV3IfNeeded()` if the file uses the old v2 format. This upgrades legacy fields like `tags:['claude']` to the current `agentId` shape.
3.  Returns a `ProjectFileV3` object to the renderer.

```typescript
// src/core/workspace-store.ts (excerpt)
async function load(id: string): Promise<ProjectFileV3> {
  const raw = await readFileAtomic(projectPath(id));
  const parsed = JSON.parse(raw);
  return migrateV2toV3IfNeeded(parsed);
}

```

### Rehydrating the React Flow Canvas

The renderer receives the deserialized project object and uses **`flowToNodeStates()`** to transform plain node descriptors back into React Flow-compatible objects. During reconstruction:

*   Missing fields receive **defaults** (e.g., `kind: 'terminal'` for legacy nodes).
*   UI-only data that never touched disk (current selection, temporary flags) is reapplied.

```typescript
// src/renderer/state/workspace.ts (excerpt)
function loadProject(projectData: ProjectFileV3) {
  const nodes = flowToNodeStates(projectData.nodes);
  // restore viewport, settings, etc.
  workspace.setState({ nodes, viewport: projectData.viewport });
}

```

## Critical Invariants and Guarantees

The Nodeterm source code enforces several strict invariants to ensure data integrity:

| Invariant | Where Enforced |
|-----------|----------------|
| **Transient fields are never persisted** | `nodeStatesToFlow()` in [`workspace.ts`](https://github.com/eneskirca/nodeterm/blob/main/workspace.ts) drops `initialCommand`, `respawnNonce`, and `remote` |
| **Atomic writes on all platforms** | `writeFileAtomic()` in [`fs-atomic.ts`](https://github.com/eneskirca/nodeterm/blob/main/fs-atomic.ts) prevents Windows `EPERM` and POSIX race conditions (validated by [`fs-atomic.guard.test.ts`](https://github.com/eneskirca/nodeterm/blob/main/fs-atomic.guard.test.ts)) |
| **Automatic legacy migration** | `migrateV2toV3IfNeeded()` in [`workspace-store.ts`](https://github.com/eneskirca/nodeterm/blob/main/workspace-store.ts) upgrades v2 projects to v3 on load |
| **Serialization consistency** | Pure functions `nodeStatesToFlow` and `flowToNodeStates` are shared between local saves and collaborative canvas streams ([`src/shared/canvas-publish.ts`](https://github.com/eneskirca/nodeterm/blob/main/src/shared/canvas-publish.ts)) |

These guarantees enable projects to be hot-switched between tabs without losing state, cold-restored after machine reboots, and shared via Git using the human-readable [`.nodeterm/project.json`](https://github.com/eneskirca/nodeterm/blob/main/.nodeterm/project.json) file.

## Summary

*   **Nodeterm project serialization** relies on a strict separation between renderer state extraction and core atomic I/O.
*   The **`nodeStatesToFlow()`** function ensures only stable data reaches the disk by filtering transient runtime fields.
*   **Atomic file writes** via `writeFileAtomic()` prevent corruption across Windows, macOS, and Linux.
*   **Automatic migration** logic upgrades legacy v2 schemas to v3 transparently when loading.
*   Deserialization uses **`flowToNodeStates()`** to reconstruct React Flow nodes while reapplying UI-specific ephemeral state.

## Frequently Asked Questions

### How does Nodeterm prevent data loss during crashes?

Nodeterm uses atomic file operations implemented in [`src/core/fs-atomic.ts`](https://github.com/eneskirca/nodeterm/blob/main/src/core/fs-atomic.ts). The `writeFileAtomic()` function writes content to a temporary file first, then performs an atomic filesystem rename. This ensures that the existing project file is only replaced once the new data is fully written to disk, preventing partial writes or corruption if the app crashes mid-save.

### What happens when opening an old Nodeterm project?

The `load()` function in [`src/core/workspace-store.ts`](https://github.com/eneskirca/nodeterm/blob/main/src/core/workspace-store.ts) automatically detects v2 schemas and runs `migrateV2toV3IfNeeded()`. This migrates deprecated fields like `tags:['claude']` to the modern `agentId` format, ensuring backward compatibility without manual user intervention.

### Which project data is not saved to disk?

Transient runtime fields including `initialCommand`, `respawnNonce`, and `remote` are intentionally stripped by `nodeStatesToFlow()` during serialization. These represent live session state that should not persist across restarts. UI-only flags like temporary selection states are also excluded from the JSON file but reapplied during deserialization.

### Can Nodeterm projects be version controlled?

Yes. The [`.nodeterm/project.json`](https://github.com/eneskirca/nodeterm/blob/main/.nodeterm/project.json) file is a plain, human-readable JSON document with stable schema versioning. Because it uses deterministic serialization with `JSON.stringify(project, null, 2)`, it integrates cleanly with Git and other version control systems, allowing teams to share terminal workspace configurations alongside source code.