lundie.io Get In Touch

Phase 6: The Great Refactor

1–22 September 2025

September began with a deliberate pause. The tree worked, the mutation queue worked, the API integration worked – but the codebase had accumulated enough structural debt that adding anything new was getting harder. I wrote out a phased refactoring plan and worked through it over three weeks.

Stage 0: Cleanup

The first step was deletion. DragPerformanceTest.tsx was a dev artifact from Phase 5. NestedSortableTree-bu.tsx was a backup that had outlived its purpose. EdgeManager.ts was more interesting.

I had extracted EdgeManager from NodeWorkflowManager a few weeks earlier – a deliberate attempt to draw a cleaner boundary around edge operations. It seemed like the right split. In practice it created friction: both classes were too entangled to stay independent. EdgeManager ended up referenced only in tests. The boundary I'd drawn hadn't emerged naturally from the code – I'd imposed it.

Graphs and trees weren't new to me theoretically. Using them at this scale in a real project – where the line between tree structure and graph relationships is deliberately blurred – was different. The obvious refactoring seams from a relational or CRUD background don't appear in the same places. I chose to move on after extracting useful logic from the EdgeManager and folding it into the facade.

Stage 1: Unified Mutations Facade

The mutation layer had grown across multiple files: a raw mutationQueue, a TreeMutationService that wrapped it and UI components importing both directly. The Stage 1 goal was a single surface – one thing the UI talked to, with everything else hidden behind it.

The result was a typed interface and a singleton:

MutationsContract – the public API
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export interface MutationsContract {
    syncTreeChanges(oldTree: TreeItemData[], newTree: TreeItemData[], userId: string): void;
    moveNodeToParent(nodeId: string, prevParentId: string | null, newParentId: string, edgeType: EdgeType, userId: string): void;
    markNodesForDeletion(nodes: Array<{ id: string; type?: string }>, userId: string): void;
    createEdge(parentId: string, childId: string, edgeType: EdgeType, userId: string): void;
    deleteEdge(parentId: string, childId: string, edgeType: EdgeType, userId: string): void;
    cancelDeletions(nodeIds: string[]): void;
    saveNow(): void;
    onStatus(cb: (status: MutationStatus) => void): () => void;
    getStatus(): MutationStatus;
}

// Singleton – the only import UI components need
export const mutations: MutationsContract = new MutationsQueueService();

UI components that had been reaching into mutationQueue directly were updated to call mutations.* instead. TreeMutationService was deleted once its logic was absorbed. The staged deletion system – mark nodes, confirm or undo before committing – also landed in this commit, built on top of the new facade.

Stage 2: Hook Extraction – Deferred

The plan called for breaking NestedSortableTree.tsx into focused custom hooks: useTreeSync for server reconciliation, useTreeOperations for create/delete/move, useDragDropHandlers for @dnd-kit events. Target was 150–200 lines for the component itself.

This was deferred. The component was working, the facade gave it a cleaner external interface and other things needed building. The refactoring boundary was clear on paper but would have meant touching everything at once. It waited – and is still waiting.

The camelCase Crisis

This was the first project where I was building a Python backend and a TypeScript frontend at the same time, from scratch. Early on I made a pragmatic decision: use snake_case in the TypeScript mutation types to match the Python backend. It avoided a translation layer and meant I didn't have to hold two naming conventions in my head simultaneously.

As the frontend grew, this started working against me. TypeScript naturally pulls toward camelCase – it's what every library, every React pattern, every IDE completion assumes. The snake_case was getting in the way of my own flow. So I switched the types to camelCase, which was the right call – except the mutation queue's coalescing logic was still comparing snake_case field names against the new camelCase ones. Duplicate operations were being queued instead of merging:

The coalescing bug
1
2
3
4
5
6
7
8
9
// Operations were being created with camelCase after the type change
const op = { opId: uid(), userId, nodeId, type: 'moveNode' };

// But coalescing logic was still comparing snake_case
const existing = queue.find(o =>
    o.op_id === op.opId &&   // undefined – wrong key
    o.node_id === op.nodeId  // undefined – wrong key
);
// Result: never coalesces, every move queues a duplicate

The fix was a typed transformation function at the API boundary – camelCase throughout the TypeScript codebase, converted to snake_case only when the request was sent:

transformOperationToSnakeCase – API boundary transform
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function transformOperationToSnakeCase(op: any): any {
    const transformed: any = {
        op_id: op.opId,
        user_id: op.userId,
        ts: op.ts,
        type: op.type,
    };
    switch (op.type) {
        case 'moveNode':
            transformed.node_id = op.nodeId;
            transformed.new_parent_id = op.newParentId;
            transformed.edge_type = op.edgeType;
            break;
        case 'createEdge':
        case 'deleteEdge':
            transformed.from_node = op.fromNode;
            transformed.to_node = op.toNode;
            transformed.edge_type = op.edgeType;
            break;
        // ...remaining types
    }
    return transformed;
}

This is what conventions are for. The early shortcut saved a little cognitive overhead and cost a debugging session to untangle. The principle – TypeScript uses camelCase, the API boundary does the translation – should have been the starting point.

Backend: Hierarchical Edges and FieldFilter

Two backend changes closed out the phase. The hierarchical edge system formalised a distinction that had been implicit: edges were split into hierarchical (is_hierarchical=True, parent-child relationships) and associative (is_hierarchical=False, cross-references). Single-parent enforcement was added at the API level – a node could have at most one hierarchical parent, preventing tree corruption from concurrent writes.

The FieldFilter migration was forced: Firebase deprecated the old collection.where("field", "==", value) query syntax. All Firestore queries were updated to the new form before it became a runtime problem.

FieldFilter migration
1
2
3
4
5
6
# Before (deprecated)
query = collection.where("user_id", "==", user_id)

# After
from google.cloud.firestore_v1.base_query import FieldFilter
query = collection.where(filter=FieldFilter("user_id", "==", user_id))

Key Commits from This Phase

Web 59ecc90 2025-09-06
[Refactor/Feat] Unified mutations facade with staged deletion system
Web 03122c3 2025-09-22
[Fix/Refactor] Standardise mutation queue to camelCase with API transform
Web f4933f8 2025-09-20
[Refactor/Feat] Flexible hierarchical edge system with is_hierarchical flag
API 0f33bdd 2025-09-20
[Feat] Hierarchical edge system with single-parent enforcement
API e75546a 2025-09-24
[Refactor] Updating legacy Firestore where queries to use FieldFilter

Get In Touch

Prefer using email? Say hi at hello@lundie.io