Skip to content

Commit ef27ad9

Browse files
fix: orderable fractional indexing case-sensitivity issue with PostgreSQL (#14867)
## fix: orderable fractional indexing case-sensitivity issue with PostgreSQL ### Problem When using the `orderable` feature with PostgreSQL, reordering documents to the "first" position generates keys that break subsequent reordering operations. #### Scenario 1. Create two orderable documents → keys are `a0`, `a1` 2. Drag `a1` before `a0` (to make it first) 3. The system generates key `Zz` for the moved document 4. **Result**: Ordering is completely broken - cannot reorder any documents anymore #### Root Cause The [fractional indexing algorithm](https://observablehq.com/@dgreensp/implementing-fractional-indexing) uses: - **Uppercase `A-Z`** for "smaller" integer keys - **Lowercase `a-z`** for "larger" integer keys This relies on **ASCII ordering** where `'Z'` (code 90) < `'a'` (code 97), so `Zz < a0`. **However**, PostgreSQL's default collation (`en_US.UTF-8`) uses **case-insensitive comparison**, treating `'Z'` as `'z'`. This means: - ASCII: `Zz < a0` ✓ - PostgreSQL: `Zz` treated as `zz`, so `zz > a0` ✗ This mismatch causes: 1. Database queries return incorrect adjacent documents 2. The `generateKeyBetween` function receives arguments in wrong order 3. Subsequent reorder attempts either error out or create more broken keys ### Solution Modified the fractional indexing algorithm to use only characters that sort consistently across **all** database collations: | Range | Old Encoding | New Encoding | |-------|--------------|--------------| | Small keys | `A-Z` (uppercase) | `0-9` (digits) | | Large keys | `a-z` (lowercase) | `a-z` (lowercase, unchanged) | **Key insight**: Digits (`0-9`) **always** sort before letters in both ASCII ordering and case-insensitive collations. #### Before (broken) ``` decrementInteger('a0') → 'Zz' 'Zz' < 'a0' in ASCII ✓ 'Zz' > 'a0' in PostgreSQL (case-insensitive) ✗ ``` #### After (fixed) ``` decrementInteger('a0') → '9z' '9z' < 'a0' in ASCII ✓ '9z' < 'a0' in PostgreSQL ✓ '9z' < 'a0' in MongoDB ✓ '9z' < 'a0' in SQLite ✓ ``` ### Changes **`packages/payload/src/config/orderable/fractional-indexing.js`** - Changed integer part encoding to use digits for "small" range - Maintains backward compatibility with existing `a-z` keys - Legacy `A-Z` keys are still parsed (for backward compatibility) but won't be generated ### Backward Compatibility - ✅ Existing keys starting with `a-z` continue to work correctly - ⚠️ Existing keys starting with `A-Z` (uppercase) will be parsed but may sort incorrectly in case-insensitive databases. Users with such keys should run a migration to regenerate them. ### Testing Verified the algorithm produces correct ordering: ```javascript generateKeyBetween(null, null) // → 'a0' generateKeyBetween(null, 'a0') // → '9z' (previously 'Zz') generateKeyBetween(null, '9z') // → '9y' // Sorting works correctly ['9z', 'a0', 'a1'].sort() // → ['9z', 'a0', 'a1'] ✓ ``` ### Related - Fractional Indexing algorithm: https://observablehq.com/@dgreensp/implementing-fractional-indexing - Original library: https://github.com/rocicorp/fractional-indexing --------- Co-authored-by: Paul Popus <paul@payloadcms.com>
1 parent e95f26d commit ef27ad9

File tree

2 files changed

+235
-23
lines changed

2 files changed

+235
-23
lines changed

packages/payload/src/config/orderable/fractional-indexing.js

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
11
// @ts-no-check
22

33
/**
4-
* THIS FILE IS COPIED FROM:
4+
* THIS FILE IS BASED ON:
55
* https://github.com/rocicorp/fractional-indexing/blob/main/src/index.js
66
*
7-
* I AM NOT INSTALLING THAT LIBRARY BECAUSE JEST COMPLAINS ABOUT THE ESM MODULE AND THE TESTS FAIL.
8-
* DO NOT MODIFY IT
9-
* ALSO, I'M DISABLING TS WITH `@ts-no-check` BECAUSE THEY DON'T USE STRICT NULL CHECKS IN THAT REPOSITORY
7+
* MODIFIED FOR PAYLOAD CMS:
8+
* - Changed the integer part encoding to use only digits for "small" keys and
9+
* only lowercase letters for "large" keys, ensuring consistent ordering
10+
* across databases with different collations.
11+
*
12+
* - Original algorithm used A-Z (uppercase) for "smaller" integers and a-z (lowercase)
13+
* for "larger" integers, relying on ASCII ordering where 'Z' < 'a'.
14+
*
15+
* - Some databases (e.g., PostgreSQL with default collation) use case-insensitive
16+
* comparison, treating 'Z' as 'z', which breaks the ordering.
17+
*
18+
* - New encoding:
19+
* - Uses digits '0'-'9' for "small" integers (10 values, lengths 11 down to 2)
20+
* - Uses lowercase 'a'-'z' for "large" integers (26 values, lengths 2 up to 27)
21+
* - Digits ALWAYS sort before letters in both ASCII and case-insensitive orderings.
22+
*
23+
* - Ordering: '0...' < '1...' < ... < '9..' < 'a.' < 'b..' < ... < 'z...'
24+
*
25+
* BACKWARD COMPATIBILITY:
26+
* - Existing keys starting with lowercase 'a'-'z' remain valid and work correctly.
27+
* - Keys starting with uppercase 'A'-'Z' (from the old algorithm) will still be
28+
* parsed for backward compatibility, but they may sort incorrectly in
29+
* case-insensitive databases. Consider running a migration to convert them.
1030
*/
1131

1232
// License: CC0 (no rights reserved).
@@ -80,14 +100,25 @@ function validateInteger(int) {
80100
}
81101

82102
/**
103+
* Returns the length of the integer part based on the head character.
104+
*
105+
* New encoding (case-insensitive safe):
106+
* - SMALL range (digits): '0' = 11 chars, '1' = 10 chars, ..., '9' = 2 chars
107+
* - LARGE range (lowercase): 'a' = 2 chars, 'b' = 3 chars, ..., 'z' = 27 chars
108+
*
109+
* Legacy encoding (for backward compatibility with existing keys):
110+
* - 'A'-'Z' uppercase: 'A' = 27 chars, 'B' = 26 chars, ..., 'Z' = 2 chars
111+
*
83112
* @param {string} head
84113
* @return {number}
85114
*/
86-
87115
function getIntegerLength(head) {
88-
if (head >= 'a' && head <= 'z') {
116+
if (head >= '0' && head <= '9') {
117+
return 11 - (head.charCodeAt(0) - '0'.charCodeAt(0))
118+
} else if (head >= 'a' && head <= 'z') {
89119
return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2
90120
} else if (head >= 'A' && head <= 'Z') {
121+
// Legacy encoding
91122
return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2
92123
} else {
93124
throw new Error('invalid order key head: ' + head)
@@ -107,13 +138,23 @@ function getIntegerPart(key) {
107138
return key.slice(0, integerPartLength)
108139
}
109140

141+
/**
142+
* Smallest possible key (for validation)
143+
* '0' + 10 zeros = smallest valid key in new format
144+
*/
145+
const SMALLEST_KEY = '0' + BASE_36_DIGITS[0].repeat(10)
146+
110147
/**
111148
* @param {string} key
112149
* @param {string} digits
113150
* @return {void}
114151
*/
115152

116153
function validateOrderKey(key, digits) {
154+
if (key === SMALLEST_KEY) {
155+
throw new Error('invalid order key: ' + key)
156+
}
157+
// Legacy check for old format
117158
if (key === 'A' + digits[0].repeat(26)) {
118159
throw new Error('invalid order key: ' + key)
119160
}
@@ -147,17 +188,30 @@ function incrementInteger(x, digits) {
147188
}
148189
}
149190
if (carry) {
191+
if (head === '9') {
192+
return 'a' + digits[0]
193+
}
194+
// Handle legacy uppercase transition
150195
if (head === 'Z') {
151196
return 'a' + digits[0]
152197
}
153198
if (head === 'z') {
154199
return null
155200
}
156-
const h = String.fromCharCode(head.charCodeAt(0) + 1)
157-
if (h > 'a') {
201+
202+
let h
203+
if (head >= '0' && head <= '8') {
204+
h = String.fromCharCode(head.charCodeAt(0) + 1)
205+
digs.pop()
206+
} else if (head >= 'a' && head <= 'y') {
207+
h = String.fromCharCode(head.charCodeAt(0) + 1)
158208
digs.push(digits[0])
159-
} else {
209+
} else if (head >= 'A' && head <= 'Y') {
210+
// Legacy uppercase
211+
h = String.fromCharCode(head.charCodeAt(0) + 1)
160212
digs.pop()
213+
} else {
214+
throw new Error('invalid head: ' + head)
161215
}
162216
return h + digs.join('')
163217
} else {
@@ -187,16 +241,28 @@ function decrementInteger(x, digits) {
187241
}
188242
if (borrow) {
189243
if (head === 'a') {
190-
return 'Z' + digits.slice(-1)
244+
return '9' + digits.slice(-1)
191245
}
192-
if (head === 'A') {
246+
if (head === '0') {
193247
return null
194248
}
195-
const h = String.fromCharCode(head.charCodeAt(0) - 1)
196-
if (h < 'Z') {
249+
250+
let h
251+
if (head >= '1' && head <= '9') {
252+
h = String.fromCharCode(head.charCodeAt(0) - 1)
197253
digs.push(digits.slice(-1))
198-
} else {
254+
} else if (head >= 'b' && head <= 'z') {
255+
h = String.fromCharCode(head.charCodeAt(0) - 1)
199256
digs.pop()
257+
} else if (head >= 'B' && head <= 'Z') {
258+
// Legacy uppercase
259+
h = String.fromCharCode(head.charCodeAt(0) - 1)
260+
digs.push(digits.slice(-1))
261+
} else if (head === 'A') {
262+
// Legacy uppercase
263+
return null
264+
} else {
265+
throw new Error('invalid head: ' + head)
200266
}
201267
return h + digs.join('')
202268
} else {
@@ -232,6 +298,10 @@ export function generateKeyBetween(a, b, digits = BASE_36_DIGITS) {
232298

233299
const ib = getIntegerPart(b)
234300
const fb = b.slice(ib.length)
301+
if (ib === SMALLEST_KEY) {
302+
return ib + midpoint('', fb, digits)
303+
}
304+
// Legacy check
235305
if (ib === 'A' + digits[0].repeat(26)) {
236306
return ib + midpoint('', fb, digits)
237307
}

test/sort/int.spec.ts

Lines changed: 151 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,7 @@ describe('Sort', () => {
526526

527527
expect(orderable1._order).toBeDefined()
528528
expect(orderable2._order).toBeDefined()
529-
expect(parseInt(orderable1._order, 16)).toBeLessThan(parseInt(orderable2._order, 16))
529+
expect(parseInt(orderable1._order, 36)).toBeLessThan(parseInt(orderable2._order, 36))
530530
expect(ordered.docs[0].id).toStrictEqual(orderable1.id)
531531
expect(ordered.docs[1].id).toStrictEqual(orderable2.id)
532532
})
@@ -556,8 +556,8 @@ describe('Sort', () => {
556556
},
557557
})
558558

559-
expect(parseInt(ordered.docs[0]._order, 16)).toBeLessThan(
560-
parseInt(ordered.docs[1]._order, 16),
559+
expect(parseInt(ordered.docs[0]._order, 36)).toBeLessThan(
560+
parseInt(ordered.docs[1]._order, 36),
561561
)
562562
})
563563

@@ -589,8 +589,8 @@ describe('Sort', () => {
589589

590590
expect(ordered.docs).toHaveLength(2)
591591

592-
expect(parseInt(ordered.docs[0]._order, 16)).toBeLessThan(
593-
parseInt(ordered.docs[1]._order, 16),
592+
expect(parseInt(ordered.docs[0]._order, 36)).toBeLessThan(
593+
parseInt(ordered.docs[1]._order, 36),
594594
)
595595
})
596596

@@ -606,7 +606,7 @@ describe('Sort', () => {
606606
data: {},
607607
})
608608
expect(docDuplicated.title).toBe('new document')
609-
expect(parseInt(doc._order!, 16)).toBeLessThan(parseInt(docDuplicated._order!, 16))
609+
expect(parseInt(doc._order!, 36)).toBeLessThan(parseInt(docDuplicated._order!, 36))
610610

611611
await restClient.POST('/reorder', {
612612
body: JSON.stringify({
@@ -626,8 +626,8 @@ describe('Sort', () => {
626626
collection: 'orderable',
627627
id: docDuplicated.id,
628628
})
629-
expect(parseInt(docAfterReorder._order!, 16)).toBeGreaterThan(
630-
parseInt(docDuplicatedAfterReorder._order!, 16),
629+
expect(parseInt(docAfterReorder._order!, 36)).toBeGreaterThan(
630+
parseInt(docDuplicatedAfterReorder._order!, 36),
631631
)
632632
})
633633

@@ -701,6 +701,148 @@ describe('Sort', () => {
701701
expect(orderableIndex).toBeGreaterThan(aAIndex)
702702
expect(orderableIndex).toBeLessThan(a0Index)
703703
})
704+
705+
it('should generate case-insensitive-safe keys when moving to first position', async () => {
706+
const collection = orderableSlug
707+
708+
const { docs: allDocs } = await payload.find({
709+
collection,
710+
sort: '_order',
711+
limit: 1,
712+
})
713+
expect(allDocs).toHaveLength(1)
714+
const firstDoc = allDocs[0]!
715+
716+
const newDoc = await payload.create({
717+
collection,
718+
data: { title: 'Move to first test' },
719+
})
720+
721+
const res = await restClient.POST('/reorder', {
722+
body: JSON.stringify({
723+
collectionSlug: collection,
724+
docsToMove: [newDoc.id],
725+
newKeyWillBe: 'less',
726+
orderableFieldName: '_order',
727+
target: {
728+
id: firstDoc.id,
729+
key: firstDoc._order,
730+
},
731+
}),
732+
})
733+
734+
expect(res.status).toStrictEqual(200)
735+
736+
const newDocAfter = await payload.findByID({ collection, id: newDoc.id })
737+
738+
expect(newDocAfter._order).toMatch(/^\d/)
739+
expect(parseInt(newDocAfter._order!, 36)).toBeLessThan(parseInt(firstDoc._order!, 36))
740+
})
741+
742+
it('should allow multiple reorders to absolute first position', async () => {
743+
const collection = orderableSlug
744+
745+
const { docs: initialDocs } = await payload.find({
746+
collection,
747+
sort: '_order',
748+
limit: 1,
749+
})
750+
expect(initialDocs).toHaveLength(1)
751+
const originalFirst = initialDocs[0]!
752+
753+
const docA = await payload.create({
754+
collection,
755+
data: { title: 'Multi first A' },
756+
})
757+
758+
const docB = await payload.create({
759+
collection,
760+
data: { title: 'Multi first B' },
761+
})
762+
763+
await restClient.POST('/reorder', {
764+
body: JSON.stringify({
765+
collectionSlug: collection,
766+
docsToMove: [docA.id],
767+
newKeyWillBe: 'less',
768+
orderableFieldName: '_order',
769+
target: {
770+
id: originalFirst.id,
771+
key: originalFirst._order,
772+
},
773+
}),
774+
})
775+
776+
const docAAfter = await payload.findByID({ collection, id: docA.id })
777+
expect(docAAfter._order).toMatch(/^\d/)
778+
779+
const res = await restClient.POST('/reorder', {
780+
body: JSON.stringify({
781+
collectionSlug: collection,
782+
docsToMove: [docB.id],
783+
newKeyWillBe: 'less',
784+
orderableFieldName: '_order',
785+
target: {
786+
id: docA.id,
787+
key: docAAfter._order,
788+
},
789+
}),
790+
})
791+
792+
expect(res.status).toStrictEqual(200)
793+
794+
const docBAfter = await payload.findByID({ collection, id: docB.id })
795+
796+
expect(docBAfter._order).toMatch(/^\d/)
797+
expect(parseInt(docBAfter._order!, 36)).toBeLessThan(parseInt(docAAfter._order!, 36))
798+
})
799+
800+
it('should handle moving doc from first position and back', async () => {
801+
const collection = orderableSlug
802+
803+
const { docs: initialDocs } = await payload.find({
804+
collection,
805+
sort: '_order',
806+
limit: 2,
807+
})
808+
expect(initialDocs.length).toBeGreaterThanOrEqual(2)
809+
const firstDoc = initialDocs[0]!
810+
const secondDoc = initialDocs[1]!
811+
812+
await restClient.POST('/reorder', {
813+
body: JSON.stringify({
814+
collectionSlug: collection,
815+
docsToMove: [firstDoc.id],
816+
newKeyWillBe: 'greater',
817+
orderableFieldName: '_order',
818+
target: {
819+
id: secondDoc.id,
820+
key: secondDoc._order,
821+
},
822+
}),
823+
})
824+
825+
const firstDocMoved = await payload.findByID({ collection, id: firstDoc.id })
826+
expect(parseInt(firstDocMoved._order!, 36)).toBeGreaterThan(parseInt(secondDoc._order!, 36))
827+
828+
const res = await restClient.POST('/reorder', {
829+
body: JSON.stringify({
830+
collectionSlug: collection,
831+
docsToMove: [firstDoc.id],
832+
newKeyWillBe: 'less',
833+
orderableFieldName: '_order',
834+
target: {
835+
id: secondDoc.id,
836+
key: secondDoc._order,
837+
},
838+
}),
839+
})
840+
841+
expect(res.status).toStrictEqual(200)
842+
843+
const firstDocBack = await payload.findByID({ collection, id: firstDoc.id })
844+
expect(parseInt(firstDocBack._order!, 36)).toBeLessThan(parseInt(secondDoc._order!, 36))
845+
})
704846
})
705847

706848
describe('Orderable join', () => {
@@ -775,7 +917,7 @@ describe('Sort', () => {
775917
depth: 1,
776918
})
777919
const orders = (related.orderableJoinField1 as { docs: Orderable[] }).docs.map((doc) =>
778-
parseInt(doc._orderable_orderableJoinField1_order, 16),
920+
parseInt(doc._orderable_orderableJoinField1_order, 36),
779921
) as [number, number, number]
780922
expect(orders[0]).toBeLessThan(orders[1])
781923
expect(orders[1]).toBeLessThan(orders[2])

0 commit comments

Comments
 (0)