Implement differential sync for organization structure and positions instead of delete-all-and-insert-all approach. Changes: - Add OrgIdMapping and AllOrgMappings interfaces for tracking ID mappings - Implement syncOrgLevel() helper for differential sync per org level - Add syncPositionsForPosMaster() helper for position table sync - Process org levels bottom-up (Child4→Child3→Child2→Child1→Root) - Use ancestorDNA matching with Like operator for descendant sync - Cascade delete positions before deleting org nodes - Batch DELETE/UPDATE/INSERT operations for better performance - Track draft→current ID mappings for position updates - Add comprehensive documentation in docs/move-draft-to-current.md Benefits: - Preserve IDs for unchanged nodes (better tracking) - More efficient (fewer database operations) - Better data integrity with proper FK handling - Sync all descendants under given rootDnaId Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
10 KiB
Move Draft to Current - Differential Sync Implementation
Overview
This document describes the implementation of the improved move-draft-to-current function in OrganizationController.ts. The function synchronizes organization structure and position data from the Draft Revision to the Current Revision using a differential sync approach (instead of the previous "delete all and insert all" method).
API Endpoint: POST /api/v1/org/move-draft-to-current/{rootDnaId}
Architecture
Data Models
The organization structure consists of 5 hierarchical levels:
OrgRoot (Level 0)
└── OrgChild1 (Level 1)
└── OrgChild2 (Level 2)
└── OrgChild3 (Level 3)
└── OrgChild4 (Level 4)
Each level has:
- Organization nodes with
ancestorDNAfor hierarchical tracking - Foreign key relationships to parent levels
- Associated position records (
PosMaster)
Type Definitions
Located in src/interfaces/OrgMapping.ts:
interface OrgIdMapping {
byAncestorDNA: Map<string, string>; // ancestorDNA → current ID
byDraftId: Map<string, string>; // draft ID → current ID
}
interface AllOrgMappings {
orgRoot: OrgIdMapping;
orgChild1: OrgIdMapping;
orgChild2: OrgIdMapping;
orgChild3: OrgIdMapping;
orgChild4: OrgIdMapping;
}
Implementation Workflow
Phase 0: Preparation
-
Get Revision IDs
- Fetch Draft Revision (
orgRevisionIsDraft: true) - Fetch Current Revision (
orgRevisionIsCurrent: true)
- Fetch Draft Revision (
-
Validate rootDnaId
- Check if rootDnaId exists in Draft Revision
- Return error if not found
Phase 1: Sync Organization Structure (Bottom-Up)
Processing Order: OrgChild4 → OrgChild3 → OrgChild2 → OrgChild1 → OrgRoot
Why Bottom-Up? Child nodes have no dependent children (only parent references), allowing safe deletion without FK violations.
For Each Organization Level
The syncOrgLevel() helper performs:
-
FETCH - Get all draft and current nodes under
rootDnaIdwhere: { ancestorDNA: Like(`${rootDnaId}%`) } // All descendants -
DELETE - Remove current nodes not in draft
- Cascade delete positions first (via
cascadeDeletePositions()) - Delete the organization node
- Cascade delete positions first (via
-
UPDATE - Update nodes that exist in both (matched by
ancestorDNA)- Map parent IDs using
parentMappings - Preserve original node ID
- Map parent IDs using
-
INSERT - Add draft nodes not in current
- Create new node with mapped parent IDs
- Return new ID for tracking
-
RETURN - Return
OrgIdMappingfor next level
Result: allMappings contains draft ID → current ID mappings for all org levels
Phase 2: Sync Position Data
Step 2.1: Clear current_holderId
// Clear holders for positions that will have new holders
await queryRunner.manager.update(PosMaster,
{ current_holderId: In(nextHolderIds) },
{ current_holderId: null, isSit: false }
)
Step 2.2: Fetch Draft and Current Positions
- Get draft positions using
draftOrgIdsfromallMappings - Get current positions using
currentOrgIdsfromallMappings
Step 2.3: Batch DELETE
// Delete current positions not in draft (cascade delete positions first)
await queryRunner.manager.delete(Position, { posMasterId: In(toDeleteIds) })
await queryRunner.manager.delete(PosMaster, toDeleteIds)
Step 2.4: Process UPDATE or INSERT
For each draft position:
- Map organization IDs using
resolveOrgId() - If exists in current → UPDATE
- If not exists → INSERT
- Track
draftPosMasterId → currentPosMasterIdmapping
Step 2.5: Sync Position Table
For each mapped PosMaster:
await syncPositionsForPosMaster(
queryRunner,
draftPosMasterId,
currentPosMasterId,
draftRevisionId,
currentRevisionId
)
Helper Functions
resolveOrgId(draftId, mapping)
Maps a draft organization ID to its current ID.
private resolveOrgId(
draftId: string | null,
mapping: OrgIdMapping
): string | null {
if (!draftId) return null;
return mapping.byDraftId.get(draftId) ?? null;
}
cascadeDeletePositions(queryRunner, node, entityClass)
Deletes positions associated with an organization node before deleting the node itself.
private async cascadeDeletePositions(
queryRunner: any,
node: any,
entityClass: any
): Promise<void> {
const whereClause = { orgRevisionId: node.orgRevisionId };
// Set FK field based on entity type
if (entityClass === OrgRoot) whereClause.orgRootId = node.id;
else if (entityClass === OrgChild1) whereClause.orgChild1Id = node.id;
// ... etc
await queryRunner.manager.delete(PosMaster, whereClause);
}
syncOrgLevel(...)
Generic differential sync for each organization level.
Parameters:
queryRunner- Database query runnerentityClass- Organization entity class (OrgRoot, OrgChild1, etc.)repository- Repository for the entitydraftRevisionId- Draft revision IDcurrentRevisionId- Current revision IDrootDnaId- Root DNA ID to sync underparentMappings- Mappings from child levels (for FK resolution)
Returns: OrgIdMapping for this level
syncPositionsForPosMaster(...)
Syncs positions for a PosMaster record.
Parameters:
queryRunner- Database query runnerdraftPosMasterId- Draft PosMaster IDcurrentPosMasterId- Current PosMaster IDdraftRevisionId- Draft revision IDcurrentRevisionId- Current revision ID
Process:
- Fetch draft and current positions
- Delete current positions not in draft (by
orderNo) - Update existing positions
- Insert new positions
Transaction Management
All operations are wrapped in a transaction:
const queryRunner = AppDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// ... all sync operations
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw new HttpError(HttpStatusCode.INTERNAL_SERVER_ERROR, "...");
}
Benefits of Differential Sync
| Aspect | Old Approach (Delete All + Insert) | New Approach (Differential Sync) |
|---|---|---|
| ID Preservation | All IDs changed | Unchanged nodes keep original IDs |
| Performance | N deletes + N inserts | Only changed data processed |
| Tracking | Cannot track what changed | Can track additions/updates/deletes |
| Data Integrity | Higher risk of data loss | Better integrity with cascade deletes |
| Scalability | Poor for large datasets | Efficient with batch operations |
Database Schema Relationships
orgRevision
├── orgRoot (FK: orgRevisionId)
│ ├── orgChild1 (FK: orgRootId, orgRevisionId)
│ │ ├── orgChild2 (FK: orgChild1Id, orgRootId, orgRevisionId)
│ │ │ ├── orgChild3 (FK: orgChild2Id, orgChild1Id, orgRootId, orgRevisionId)
│ │ │ │ └── orgChild4 (FK: orgChild3Id, orgChild2Id, orgChild1Id, orgRootId, orgRevisionId)
│ │ │ │
│ │ │ └── posMaster (FK: orgRootId, orgChild1Id, orgChild2Id, orgChild3Id, orgChild4Id)
│ │ │ └── position (FK: posMasterId)
│ │ │
│ │ └── posMaster
│ │
│ └── posMaster
│
└── (current revision similar structure)
Error Handling
| Error Condition | Response |
|---|---|
| Draft/Current revision not found | 404 NOT_FOUND |
| rootDnaId not found in draft | 404 NOT_FOUND |
| No positions in draft structure | 404 NOT_FOUND |
| Database error during sync | 500 INTERNAL_SERVER_ERROR (rollback) |
Files Modified/Created
src/
├── interfaces/
│ └── OrgMapping.ts [NEW] Type definitions
├── controllers/
│ └── OrganizationController.ts [MODIFIED] moveDraftToCurrent function + helpers
docs/
└── move-draft-to-current.md [NEW] This documentation
Testing Considerations
Unit Tests
- Test
syncOrgLevel()for each org level with various scenarios - Test
resolveOrgId()mapping function - Test
cascadeDeletePositions()function - Test
syncPositionsForPosMaster()function
Integration Tests
- Empty Draft - Sync with no draft data
- Full Replacement - All nodes changed
- Partial Update - Some nodes added, some updated, some deleted
- Position Sync - Verify position table syncs correctly
- Foreign Key Constraints - Verify all FK relationships maintained
Manual Testing Flow
- Create draft structure with various changes:
- Add new department
- Modify existing department
- Remove existing department
- Add/modify/remove positions
- Call
move-draft-to-currentAPI - Verify:
- New departments appear in current
- Modified departments are updated (not recreated)
- Removed departments are gone (with positions cascade deleted)
- Positions have correct new org IDs
- Position table records sync correctly
- All foreign key constraints satisfied
Performance Optimization
- Batch Operations - Use
In()clause for multiple IDs - Map Lookups - Use
Mapfor O(1) lookups instead of array searches - Bottom-Up Processing - Minimize FK constraint checks
- Parallel Queries - Use
Promise.all()for independent queries
Future Improvements
- Parallel Processing - Process independent org branches in parallel
- Incremental Sync - Only sync changed subtrees
- Caching - Cache org mappings for repeated operations
- Audit Log - Track all changes for audit purposes
- Validation - Add pre-sync validation to catch errors early
References
- TypeORM Documentation: https://typeorm.io/
- TSOA Documentation: https://tsoa-community.github.io/
- Project Repository: [Internal Git]
Last Updated: 2025-02-09 Author: Claude Code Version: 1.0.0