Draft & Publish Relation Synchronization#
When Strapi publishes or discards a draft, it deletes the existing database row and inserts a new one, assigning the entry a fresh primary key . This ID change breaks many-to-many join-table ordering from the inverse side — newly inserted join rows have no established order and land at the end of any sequence. A capture → restore mechanism in the document service prevents this data loss.
Utility Modules#
Three parallel modules handle synchronization, each following the same two-phase load() / sync() pattern :
| Module | Relation Type |
|---|---|
bidirectional-relations.ts | Many-to-many — owning and inverse sides |
unidirectional-relations.ts | Non-bidirectional relations |
self-referential-relations.ts | Relations where both sides point to the same content type (e.g., category.parent) |
All three are orchestrated by repository.ts inside publish() and discardDraft().
The Core Problem: ID Change on State Transition#
Every publish or discard-draft cycle deletes the old row and inserts a new one with a new id. The owning side of a join table is fine — owning-side order is re-written from the draft by entries.publish. The inverse side is not: from the related entry's perspective, the new join row has no order and lands last .
Example:
Before publish: Category(id:5) →(order:3)→ Article(id:1)
Without sync: Category(id:5) →(order:99)→ Article(id:2) ← order lost
After sync: Category(id:5) →(order:3)→ Article(id:2) ← order restored
Two-Phase Sync: load() and sync()#
Phase 1 — load(): Capture before state change#
load() runs inside a transaction, before old rows are deleted. It iterates all models (content types + components), filters to join-table attributes that reference the transitioning content type, and calls captureJoinBatches() for each qualifying attribute.
captureJoinBatches() uses two capture paths :
- Existing versions (
oldVersions): reads current published (or draft) join rows about to be deleted. The owning side is skipped (isOwningSide: true) — its order comes from the draft, so reading old published rows would silently revert a user's reorder. - New locales (
draftsOnly): for a first-time publish of a locale, reads draft-side join rows instead. If the related type has Draft & Publish enabled,draftToPublishedMap()remaps draft IDs → published IDs by matchingdocument_id + locale.
Phase 2 — sync(): Restore after state change#
sync() runs after new rows exist:
- Builds an
oldId → newIdmap keyed by locale. - Issues a batch
UPDATE … CASEto set_ordon new join rows to the captured original values. - Batch-inserts any cascade-deleted join rows (rows whose targets were deleted during the transition) back with correct new IDs and preserved order.
Orchestration in repository.ts#
Both publish() and discardDraft() follow the same four-step sequence :
1. load() — all three modules capture join-table state
2. delete — old rows removed
3. create — new rows inserted (owning-side order written from draft)
4. sync() — all three modules restore inverse-side order
For publish(), oldVersions = existing published entries, newVersions = drafts being promoted . For discardDraft(), roles reverse: oldVersions = existing drafts, newVersions = published entries converted back to draft . selfReferentialRelations.load() takes a targetStatus parameter ('published' or 'draft') to identify which side of the join table to capture.
Order Column Maintenance: cleanOrderColumns#
Separate from capture/restore, cleanOrderColumns in regular-relations.ts renumbers _ord / _ord_inv columns after deletions to keep values sequential. It uses SQL ROW_NUMBER() window functions with dialect-specific SQL for MySQL (UPDATE … JOIN) vs. PostgreSQL/SQLite (UPDATE … FROM). updateOrderColumn and updateInverseOrderColumn are run sequentially (not Promise.all) to avoid PostgreSQL deadlocks when concurrent operations lock the same join-table rows in opposite orders .
Known Issues and Fix History#
| PR / Issue | Description |
|---|---|
| PR #25764 | Introduced captureJoinBatches + draftToPublishedMap; fixed inverse-side order loss on unpublish/republish |
| PR #25792 | Extended fix to the owning side and interleaved-publishing scenarios |
| PR #25890 | Added self-referential-relations.ts; relations like category.parent were silently dropped during publish/discard |
| PR #26112 | Fixed reorder-to-position-1 by adding { start: true } placement and QueryBuilder.min(); fixed order: 0 treated as falsy |
| PR #26134 | Sequential cleanOrderColumns updates to avoid PostgreSQL deadlocks |
| PR #27116 | Fixed connect/disconnect delta handling for single-media morph fields (wipe-and-replace was incorrectly used instead of delta semantics) |
| Issue #26786 | Open: Republishing after reordering M2M relations does not propagate new order to published version. Root cause: captureJoinBatches finds draftsOnly empty for an already-published locale, falls back to old published join rows, and overwrites the user's draft reorder — affects only re-published entries, not newly published ones |