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:
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:
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:
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.
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))