├── .gitignore ├── .nvmrc ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── docs ├── fractionalApi.md └── generator.md ├── examples └── react │ ├── .gitignore │ ├── README.md │ ├── package-lock.json │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ ├── src │ ├── App.css │ ├── App.test.tsx │ ├── App.tsx │ ├── components │ │ ├── Button.tsx │ │ ├── ButtonBar.tsx │ │ ├── Card.tsx │ │ ├── Introduction.tsx │ │ ├── List.tsx │ │ ├── Name.tsx │ │ ├── Page.tsx │ │ ├── SmallText.tsx │ │ └── componentCss.css │ ├── examples │ │ ├── groups.tsx │ │ ├── interleaving.tsx │ │ ├── interleavingWithoutJitter.tsx │ │ ├── memoizedGenerator.tsx │ │ ├── simple.tsx │ │ └── simpleListWithoutJitter.tsx │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── setupTests.ts │ └── utils │ │ └── uid.ts │ └── tsconfig.json ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── IndexGenerator.spec.ts ├── IndexGenerator.ts ├── __tests__ │ ├── bruteForce.spec.ts │ └── readme.spec.ts ├── charSet.spec.ts ├── charSet.ts ├── generateKeyBetween.spec.ts ├── generateKeyBetween.ts ├── index.ts ├── integer.spec.ts ├── integer.ts ├── integerLength.spec.ts ├── integerLength.ts ├── jittering.spec.ts ├── jittering.ts ├── keyAsNumber.spec.ts ├── keyAsNumber.ts └── padToSameLength.ts ├── tsconfig.base.json ├── tsconfig.cjs.json └── tsconfig.esm.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | coverage/ 4 | *.tgz 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.16.0 -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Jest Tests", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeArgs": [ 9 | "--inspect-brk", 10 | "${workspaceRoot}/node_modules/.bin/jest", 11 | "--runInBand" 12 | ], 13 | "console": "integratedTerminal", 14 | "internalConsoleOptions": "neverOpen" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fractional indexing jittered 2 | 3 | Goal of this package is a abstraction to use Fractional indexing, 4 | a technique to generate new order keys in between a existing list without 5 | having to reorder all the existing keys. 6 | 7 | This package supports [Jittering](#jittering) the key to have a high probability of a unique key. 8 | 9 | The package consist of two parts: 10 | - [Fractional index API](#fractional-index-api) is a collection of functions to generate order keys, either jittered or not 11 | - [Generator](#generator-quick-start) is a class to help with the generator process, with support for groups 12 | 13 | This package builds on a solid foundation both in writing and in code, see [credits](#credits) 14 | 15 | Some examples using react are available in the examples folder and can be viewed on [Github Pages](https://tmeerhof.github.io/fractional-indexing-jittered) 16 | 17 | ## Jittering 18 | Both the low level API and generator the support jittering, see [credits](#random-jitter) for more background info. 19 | This means that the keys are with high probability unique even when multiple actors insert on the same spot at the same time 20 | 21 | The default character set has a chance of roughly one in 47.000 to generate the same key for the same input at the cost of making the keys 3 characters longer on average. 22 | (Not taking into account that `Math.random` is not 100% random) 23 | 24 | ## Fractional index API 25 | 4 functions to generate keys: 26 | - generateKeyBetween -> generates a single key between two keys or at the start or end of the list 27 | - generateNKeysBetween -> generate N consecutive keys between two keys or at the start or end of the list 28 | - generateJitteredKeyBetween -> generates a single key with Jittering 29 | - generateNJitteredKeysBetween > generate N consecutive keys with Jittering 30 | 31 | 1 utility functions 32 | - indexCharacterSet -> create a custom character set if you want more control 33 | 34 | See [Fractional index API](./docs/fractionalApi.md) 35 | 36 | ## Generator Quick Start 37 | The easiest way is to use the index generator, the generator should be updated with the latest list 38 | after you processed the generated order keys, or if there are updates from other sources like the server/CRDT. 39 | 40 | The default will use a base62 character set, with generated keys starting from 'a0', 'a1', 'a2' etc, with random [jitter](#jittering) 41 | 42 | Read more about Generator Groups and the API at the [Generator Docs](./docs/generator.md) 43 | 44 | ```ts 45 | import { IndexGenerator } from 'fractional-indexing-jittered'; 46 | const generator = new IndexGenerator([]); 47 | 48 | // dummy code, would normally be stored in database or CRDT and updated from there 49 | const list: string[] = []; 50 | function updateList(newKey: string) { 51 | list.push(newKey); 52 | generator.updateList(list); 53 | } 54 | 55 | // "a01TB" a0 with jitter 56 | const firstKey = generator.keyStart(); 57 | updateList(firstKey); 58 | 59 | // "a10Vt" a1 with jitter 60 | const secondKey = generator.keyEnd(); 61 | updateList(secondKey); 62 | 63 | // "a0fMq" jittered midpoint between firstKey and secondKey 64 | const keyInBetween = generator.keyAfter(firstKey); 65 | updateList(keyInBetween); 66 | 67 | // "a0M3o" jittered midpoint between firstKey and keyInBetween 68 | const anotherKeyInBetween = generator.keyBefore(keyInBetween); 69 | updateList(anotherKeyInBetween); 70 | 71 | // [ 'a01TB', 'a0M3o', 'a0fMq', 'a10Vt' ] 72 | // [ firstKey, anotherKeyInBetween, keyInBetween, secondKey ] 73 | console.log(list.sort()); 74 | ``` 75 | 76 | ## Credits 77 | This package builds on a solid foundation both in writing and in code, the two most influential sources are listed below. 78 | 79 | ### fractional-indexing 80 | Starting point for this package was the [fractional-indexing](https://github.com/rocicorp/fractional-indexing) package. 81 | 82 | Kudos to them and [David Greenspan](https://github.com/dgreensp), this implementation also includes a slightly adjusted 83 | version of variable-length integers, and the prepend/append optimization described in David's article. 84 | 85 | ### Random Jitter 86 | The idea for adding random jitter to this package comes from this excellent post by Even Wallace called [CRDT: Fractional Indexing](https://madebyevan.com/algos/crdt-fractional-indexing/). 87 | It was [another](https://www.figma.com/blog/realtime-editing-of-ordered-sequences/) post by Even Wallace, that put me on the path to use fractional indexing in our app. 88 | 89 | ## Questions 90 | 91 | ### What about interleaving? 92 | If two peers both simultaneously insert at the same location, the resulting objects may be interleaved. 93 | Protecting against interleaving comes at the cost of added complexity or rapidly growing order key length. 94 | 95 | This means that this package is not well suited for situations where object adjacency is critical 96 | (like character order in collaborative text editing) 97 | 98 | ### What if I do have an identical key (even with jittering)? 99 | Identical keys still possible if two peers both simultaneously insert at the same location, even with jittering on, and likely if you do not use jittering. 100 | 101 | Best practice is use another ID as tiebreaker like the object ID. 102 | If you detect an Identical key, you can always just regenerate one (or both) 103 | 104 | ### How long can the keys get? 105 | There is no limit on how long the keys can get, 106 | and theoretically can get quite long if inserting on the same spot hundreds or thousand of times. 107 | But with human input the length of the order keys will normally stay reasonable. 108 | -------------------------------------------------------------------------------- /docs/fractionalApi.md: -------------------------------------------------------------------------------- 1 | # Fractional indexing API 2 | 4 functions to generate keys: 3 | - [generateKeyBetween](#generatekeybetween) -> generates a single key between two keys or at the start or end of the list 4 | - [generateNKeysBetween](#generatenkeysbetween) -> generate N consecutive keys between two keys or at the start or end of the list 5 | - [generateJitteredKeyBetween](#generatejitteredkeybetween) -> generates a single key with Jittering 6 | - [generateNJitteredKeysBetween](#generatenjitteredkeysbetween) > generate N consecutive keys with Jittering 7 | 8 | 1 utility functions 9 | - [indexCharacterSet](#indexcharacterset) -> create a custom character set if you want more control 10 | 11 | generateKeyBetween and generateNKeysBetween credits to the [fractional-indexing](../README.md#fractional-indexing) package 12 | 13 | ### `generateKeyBetween` 14 | 15 | Generate a single key in between two points. 16 | 17 | ```ts 18 | export function generateKeyBetween( 19 | lower: string | null, 20 | upper: string | null, 21 | charSet: IndexedCharSet = base62CharSet() // optional custom character set 22 | ): string 23 | ``` 24 | ```ts 25 | import { generateKeyBetween } from 'fractional-indexing-jittered'; 26 | 27 | const first = generateKeyBetween(null, null); // "a0" 28 | 29 | // Insert after 1st 30 | const second = generateKeyBetween(first, null); // "a1" 31 | 32 | // Insert after 2nd 33 | const third = generateKeyBetween(second, null); // "a2" 34 | 35 | // Insert before 1st 36 | const zeroth = generateKeyBetween(null, first); // "Zz" 37 | 38 | // Insert in between 2nd and 3rd (midpoint) 39 | const secondAndHalf = generateKeyBetween(second, third); // "a1V" 40 | ``` 41 | 42 | ### `generateNKeysBetween` 43 | 44 | Use this when generating multiple keys at some known position, as it spaces out indexes more evenly and leads to shorter keys. 45 | 46 | ```ts 47 | export function generateNKeysBetween( 48 | a: string | null, 49 | b: string | null, 50 | n: number, 51 | charSet: IndexedCharSet = base62CharSet() // optional custom character set 52 | ): string[] 53 | ``` 54 | 55 | ```ts 56 | import { generateNKeysBetween } from 'fractional-indexing-jittered'; 57 | 58 | const first = generateNKeysBetween(null, null, 2); // ['a0', 'a1'] 59 | 60 | // Insert two keys after 2nd 61 | generateNKeysBetween(first[1], null, 2); // ['a2', 'a3'] 62 | 63 | // Insert two keys before 1st 64 | generateNKeysBetween(null, first[0], 2); // ['Zy', 'Zz'] 65 | 66 | // Insert two keys in between 1st and 2nd (midpoints) 67 | generateNKeysBetween(second, third, 2); // ['a0G', 'a0V'] 68 | ``` 69 | 70 | ### `generateJitteredKeyBetween` 71 | 72 | Generate a single jittered key in between two points, in all other things identical to `generateKeyBetween`. 73 | 74 | ```ts 75 | export function generateJitteredKeyBetween( 76 | lower: string | null, 77 | upper: string | null, 78 | charSet: IndexedCharSet = base62CharSet() // optional custom character set 79 | ): string 80 | ``` 81 | ```ts 82 | import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; 83 | 84 | const first = generateJitteredKeyBetween(null, null); // "a07aS" 85 | 86 | // Insert after 1st 87 | const second = generateJitteredKeyBetween(first, null); // "a19el" 88 | 89 | // Insert after 2nd 90 | const third = generateJitteredKeyBetween(second, null); // "a26wi" 91 | 92 | // Insert before 1st 93 | const zeroth = generateJitteredKeyBetween(null, first); // "Zz0LR" 94 | 95 | // Insert in between 2nd and 3rd (midpoint) 96 | const secondAndHalf = generateJitteredKeyBetween(second, third); // "a1n6x" 97 | ``` 98 | 99 | ### `generateNJitteredKeysBetween` 100 | 101 | Use this when generating multiple jittered keys at some known position, as it spaces out indexes more evenly. 102 | 103 | ```ts 104 | export function generateNJitteredKeysBetween( 105 | lower: string | null, 106 | upper: string | null, 107 | n: number, 108 | charSet: IndexedCharSet = base62CharSet() // optional custom character set 109 | ): string[] 110 | ``` 111 | ```ts 112 | import { generateNJitteredKeysBetween } from 'fractional-indexing-jittered'; 113 | 114 | const first = generateNJitteredKeysBetween(null, null, 2); // ['a061p', 'a18Ev'] 115 | 116 | // Insert two keys after 2nd 117 | generateNJitteredKeysBetween(first[1], null, 2); // ['a23WQ', 'a315m'] 118 | 119 | // Insert two keys before 1st 120 | generateNJitteredKeysBetween(null, first[0], 2); // ['Zy6Gx', 'ZzB7s'] 121 | 122 | // Insert two keys in between 1st and 2nd (midpoints) 123 | generateNJitteredKeysBetween(second, third, 2); // ['a0SIA', 'a0iDa'] 124 | ``` 125 | 126 | ### `indexCharacterSet` 127 | index a custom character set, for instance if you want a different character set, 128 | or to tweak the jitter range to get shorter keys or less chance of identical keys. 129 | 130 | ```ts 131 | export interface indexCharacterSetOptions { 132 | chars: string; // sorted string of unique characters like "0123456789ABC" 133 | jitterRange?: number; // default is 1/5 of the total range created by adding 3 characters 134 | firstPositive?: string; // default is the middle character 135 | mostPositive?: string; // default is the last character 136 | mostNegative?: string; // default is the first character 137 | } 138 | export function indexCharacterSet(options: indexCharacterSetOptions): IndexedCharSet; 139 | ``` 140 | ```ts 141 | const base90Set = indexCharacterSet({ 142 | chars: 143 | "!#$%&()*+,./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`abcdefghijklmnopqrstuvwxyz{|}~", 144 | }); 145 | 146 | const first = generateKeyBetween(null, null, base90Set); // 'Q!' 147 | 148 | // Insert after 1st 149 | const second = generateKeyBetween(first, null, base90Set); // 'Q#' 150 | 151 | // Insert in between 2nd and 3rd (midpoint) 152 | const firstAndHalf = generateKeyBetween(first, second, base90Set); // 'Q!Q' 153 | 154 | // Jittering is still recommended to avoid collisions 155 | const jitteredStart = generateNJitteredKeysBetween( 156 | null, 157 | null, 158 | 2, 159 | base90Set 160 | ); // [ 'Q!$i8', 'Q#.f}' ] 161 | 162 | console.log(base90Set.jitterRange); // 145800 (so 3 times less likely to collide than base62) 163 | 164 | ``` -------------------------------------------------------------------------------- /docs/generator.md: -------------------------------------------------------------------------------- 1 | # generator 2 | 3 | Generator is a utility class for using the fractional index API, 4 | best introduction is the [quick start](../README.md#generator-quick-start) on the main Readme 5 | 6 | ## Generator Groups 7 | 8 | The generator supports groups, so you can have one ordered lists with multiple sections. 9 | You are responsible for giving the groups a name that can be alphabetically ordered. 10 | 11 | ```ts 12 | // Jitter is disabled for this example to make the output more readable, but should be preferred in production 13 | const generator = new IndexGenerator([], { 14 | useJitter: false, 15 | groupIdLength: 2, 16 | }); 17 | 18 | const list: string[] = []; 19 | // dummy code, would normally be stored in database or CRDT and updated from there 20 | function updateList(orderKey: string) { 21 | list.push(orderKey); 22 | generator.updateList(list); 23 | } 24 | 25 | // same length as groupIdLength 26 | const group1 = "g1"; 27 | const group2 = "g2"; 28 | 29 | // "g1a0" group1 and first key 30 | const first = generator.keyStart(group1); 31 | updateList(first); 32 | 33 | // "g1a1" group1 and first key 34 | const second = generator.keyEnd(group1); 35 | updateList(second); 36 | 37 | // "g1a0V" midpoint between first and second 38 | const firstAndAHalf = generator.keyAfter(first); 39 | updateList(firstAndAHalf); 40 | 41 | // "g2a0" group2 and first key 42 | const firstGroup2 = generator.keyStart(group2); 43 | updateList(firstGroup2); 44 | 45 | // ["g1a0", "g1a0V", "g1a1", "g2a0"] 46 | // [ first, firstAndAHalf, second, firstGroup2 ] 47 | console.log(list.sort()); 48 | ``` 49 | 50 | ## Generator API 51 | 52 | 53 | ### Constructor 54 | 55 | ```ts 56 | interface GeneratorOptions { 57 | charSet?: IndexedCharSet; 58 | useJitter?: boolean; 59 | groupIdLength?: number; 60 | } 61 | new IndexGenerator(list: string[], options: GeneratorOptions = {}) 62 | ``` 63 | 64 | ### Class methods 65 | ```ts 66 | /** 67 | * Updates the list that the generator uses to generate keys. 68 | * The generator will not mutate the internal list when generating keys. 69 | */ 70 | updateList(list: string[]) 71 | 72 | /** 73 | * Generate any number of keys at the start of the list (before the first key). 74 | * Optionally you can supply a groupId to generate keys at the start of a specific group. 75 | */ 76 | nKeysStart(n: number, groupId?: string): string[] 77 | 78 | /** 79 | * Generate a single key at the start of the list (before the first key). 80 | * Optionally you can supply a groupId to generate a key at the start of a specific group. 81 | */ 82 | keyStart(groupId?: string): string 83 | 84 | /** 85 | * Generate any number of keys at the end of the list (after the last key). 86 | * Optionally you can supply a groupId to generate keys at the end of a specific group. 87 | */ 88 | nKeysEnd(n: number, groupId?: string): string[] 89 | 90 | /** 91 | * Generate a single key at the end of the list (after the last key). 92 | * Optionally you can supply a groupId to generate a key at the end of a specific group. 93 | */ 94 | keyEnd(groupId?: string): string 95 | /** 96 | * Generate any number of keys behind a specific key and in front of the next key. 97 | * GroupId will be inferred from the orderKey if working with groups 98 | */ 99 | nKeysAfter(orderKey: string, n: number): string[] 100 | 101 | /** 102 | * Generate a single key behind a specific key and in front of the next key. 103 | * GroupId will be inferred from the orderKey if working with groups 104 | */ 105 | keyAfter(orderKey: string): string 106 | 107 | /** 108 | * Generate any number of keys in front of a specific key and behind the previous key. 109 | * GroupId will be inferred from the orderKey if working with groups 110 | */ 111 | nKeysBefore(orderKey: string, n: number): string[] 112 | 113 | /** 114 | * Generate a single key in front of a specific key and behind the previous key. 115 | * GroupId will be inferred from the orderKey if working with groups 116 | */ 117 | keyBefore(orderKey: string): string 118 | ``` 119 | 120 | 121 | -------------------------------------------------------------------------------- /examples/react/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /examples/react/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /examples/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fractional-indexing-jittered-examples", 3 | "version": "0.1.0", 4 | "homepage": "https://tmeerhof.github.io/fractional-indexing-jittered", 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.17.0", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "@types/jest": "^27.5.2", 10 | "@types/node": "^16.18.65", 11 | "@types/react": "^18.2.38", 12 | "@types/react-dom": "^18.2.17", 13 | "fractional-indexing-jittered": "1.0.0", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0", 16 | "react-router-dom": "^6.20.0", 17 | "react-scripts": "5.0.1", 18 | "typescript": "^4.9.5", 19 | "web-vitals": "^2.1.4" 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test", 25 | "eject": "react-scripts eject", 26 | "predeploy": "npm run build", 27 | "deploy": "gh-pages -d build" 28 | }, 29 | "eslintConfig": { 30 | "extends": [ 31 | "react-app", 32 | "react-app/jest" 33 | ] 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | }, 47 | "devDependencies": { 48 | "gh-pages": "^6.1.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /examples/react/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TMeerhof/fractional-indexing-jittered/725dfd034ab3efd5c4f4164d76bc0e0829aca299/examples/react/public/favicon.ico -------------------------------------------------------------------------------- /examples/react/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /examples/react/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TMeerhof/fractional-indexing-jittered/725dfd034ab3efd5c4f4164d76bc0e0829aca299/examples/react/public/logo192.png -------------------------------------------------------------------------------- /examples/react/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TMeerhof/fractional-indexing-jittered/725dfd034ab3efd5c4f4164d76bc0e0829aca299/examples/react/public/logo512.png -------------------------------------------------------------------------------- /examples/react/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /examples/react/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /examples/react/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/react/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /examples/react/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Routes, Route, Link } from "react-router-dom"; 2 | import "./App.css"; 3 | import { SimpleList } from "./examples/simple"; 4 | import { MemoizeGenerator } from "./examples/memoizedGenerator"; 5 | import { Interleaving } from "./examples/interleaving"; 6 | import { SimpleListWithoutJitter } from "./examples/simpleListWithoutJitter"; 7 | import { InterleavingWithoutJitter } from "./examples/interleavingWithoutJitter"; 8 | import { GroupList } from "./examples/groups"; 9 | 10 | function App() { 11 | return ( 12 |
13 | 16 | 17 | } /> 18 | } /> 19 | } 22 | /> 23 | } /> 24 | } /> 25 | } 28 | /> 29 | } /> 30 | 31 |
32 | ); 33 | } 34 | 35 | const Home = () => ( 36 | <> 37 |

Home

38 | Simple list with Jittering 39 |
40 | Simple list without Jittering 41 |
42 | Memoized generator 43 |
44 | Interleaving example with jittering 45 |
46 | 47 | Interleaving example without jittering 48 | 49 |
50 | Grouped sorted list 51 |
52 | 53 | ); 54 | 55 | export default App; 56 | -------------------------------------------------------------------------------- /examples/react/src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | export const Button = ({ 2 | children, 3 | onClick, 4 | }: { 5 | children: string; 6 | onClick: () => void; 7 | }) => ( 8 | 11 | ); 12 | -------------------------------------------------------------------------------- /examples/react/src/components/ButtonBar.tsx: -------------------------------------------------------------------------------- 1 | import "./componentCss.css"; 2 | export interface ButtonBarProps { 3 | children?: React.ReactNode; 4 | } 5 | 6 | export const ButtonBar: React.FC = ({ children }) => { 7 | return
{children}
; 8 | }; 9 | -------------------------------------------------------------------------------- /examples/react/src/components/Card.tsx: -------------------------------------------------------------------------------- 1 | import "./componentCss.css"; 2 | export interface CardProps { 3 | children?: React.ReactNode; 4 | color?: string; 5 | } 6 | 7 | export const Card: React.FC = ({ children, color = '#BAFFFA' }) => { 8 | return
{children}
; 9 | }; 10 | -------------------------------------------------------------------------------- /examples/react/src/components/Introduction.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./componentCss.css"; 3 | 4 | interface IntroductionProps { 5 | children?: React.ReactNode; 6 | } 7 | 8 | export const Introduction: React.FC = ({ children }) => { 9 | return
{children}
; 10 | }; 11 | -------------------------------------------------------------------------------- /examples/react/src/components/List.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./componentCss.css"; 3 | 4 | interface ListProps { 5 | children?: React.ReactNode; 6 | } 7 | 8 | export const List: React.FC = ({ children }) => { 9 | return
{children}
; 10 | }; 11 | -------------------------------------------------------------------------------- /examples/react/src/components/Name.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./componentCss.css"; 3 | 4 | interface NameProps { 5 | children?: React.ReactNode; 6 | } 7 | 8 | export const Name: React.FC = ({ children }) => { 9 | return
{children}
; 10 | }; 11 | -------------------------------------------------------------------------------- /examples/react/src/components/Page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "./componentCss.css"; 3 | 4 | interface PageProps { 5 | children?: React.ReactNode; 6 | } 7 | 8 | export const Page: React.FC = ({ children }) => { 9 | return ( 10 |
11 | {children} 12 |
13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /examples/react/src/components/SmallText.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const SmallText = ({ children }: { children: React.ReactNode }) => ( 4 | {children} 5 | ); 6 | -------------------------------------------------------------------------------- /examples/react/src/components/componentCss.css: -------------------------------------------------------------------------------- 1 | .list { 2 | max-width: 550px; 3 | margin: 0 auto; 4 | } 5 | 6 | nav { 7 | border-bottom: 1px solid #ccc; 8 | padding: 10px; 9 | } 10 | 11 | .page { 12 | margin: auto; 13 | max-width: 1000px; 14 | display: flex; 15 | 16 | .syncing-indicator { 17 | align-self: center; 18 | padding: 10px; 19 | } 20 | 21 | @media screen and (max-width: 700px) { 22 | flex-direction: column; 23 | } 24 | } 25 | 26 | .card { 27 | display: inline-flex; 28 | flex-direction: column; 29 | gap: 10px; 30 | justify-content: space-between; 31 | align-items: center; 32 | min-width: 250px; 33 | margin: 5px; 34 | padding: 10px; 35 | border: 1px solid #545252; 36 | border-radius: 5px; 37 | box-shadow: 0 0 10px #ccc; 38 | 39 | .name { 40 | margin-right: auto; 41 | } 42 | } 43 | 44 | .introduction { 45 | max-width: 1000px; 46 | padding: 20px; 47 | margin: 0 auto; 48 | } -------------------------------------------------------------------------------- /examples/react/src/examples/groups.tsx: -------------------------------------------------------------------------------- 1 | import{ IndexGenerator } from "fractional-indexing-jittered"; 2 | import { useState } from "react"; 3 | import { Button } from "../components/Button"; 4 | import { uid } from "../utils/uid"; 5 | import { Card } from "../components/Card"; 6 | import { Name } from "../components/Name"; 7 | import { List } from "../components/List"; 8 | import { ButtonBar } from "../components/ButtonBar"; 9 | import { SmallText } from "../components/SmallText"; 10 | import { Introduction } from "../components/Introduction"; 11 | 12 | type MyObject = { 13 | id: string; 14 | order: string; 15 | }; 16 | 17 | function sortListOnOrderKeyAndId(list: MyObject[]) { 18 | return list.sort((a, b) => { 19 | if (a.order < b.order) return -1; 20 | if (a.order > b.order) return 1; 21 | if (a.id < b.id) return -1; 22 | if (a.id > b.id) return 1; 23 | return 0; 24 | }); 25 | } 26 | 27 | type GroupKey = 'g1' | 'g2' | 'g3' | 'g4'; 28 | 29 | const groups: Record = { 30 | 'g1': 'Group 1', 31 | 'g2': 'Group 2', 32 | 'g3': 'Group 3', 33 | 'g4': 'Group 4', 34 | } 35 | 36 | const groupColors : Record = { 37 | 'g1': '#facecb', 38 | 'g2': '#badffe', 39 | 'g3': '#a7cba8', 40 | 'g4': '#f8f2b8', 41 | } 42 | 43 | export const GroupList = () => { 44 | const [list, setList] = useState(initialItems); 45 | const orderKeys = list.map((item) => item.order); 46 | // creating a new generator on every render is not a big deal for simple lists 47 | const generator = new IndexGenerator(orderKeys, { groupIdLength: 2, useJitter: false }); 48 | 49 | const addToList = (orders: string[]) => { 50 | const items = orders.map((order) => ({ id: uid(), order })); 51 | const newList = sortListOnOrderKeyAndId([...list, ...items]); 52 | setList(newList); 53 | }; 54 | 55 | const handleBefore = (orderKey: string) => { 56 | addToList([generator.keyBefore(orderKey)]); 57 | }; 58 | 59 | const handleNBefore = (orderKey: string, n: number) => { 60 | addToList(generator.nKeysBefore(orderKey, n)); 61 | }; 62 | 63 | const handleAfter = (orderKey: string) => { 64 | addToList([generator.keyAfter(orderKey)]); 65 | }; 66 | 67 | const handleNAfter = (orderKey: string, n: number) => { 68 | addToList(generator.nKeysAfter(orderKey, n)); 69 | }; 70 | 71 | return ( 72 | <> 73 | 74 | Groups can be used to create a sortable grouping of items, 75 | without the posibility of items from different groups being interleaved. 76 | 77 | 78 |
    79 | {list.map((item) => { 80 | const groupId = item.order.slice(0, 2) as GroupKey; 81 | 82 | return ( 83 |
  1. 84 | 85 | {item.order} {groups[groupId]} 86 | 87 | 90 | 93 | 94 | 97 | 98 | 99 |
  2. 100 | )})} 101 |
102 |
103 | 104 | ); 105 | }; 106 | 107 | const initialItems = Object.keys(groups).map((groupId) => ({ 108 | id: uid(), 109 | order: groupId + 'a0', 110 | })); -------------------------------------------------------------------------------- /examples/react/src/examples/interleaving.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { List } from "../components/List"; 3 | import { IndexGenerator } from "fractional-indexing-jittered"; 4 | import { Button } from "../components/Button"; 5 | import { ButtonBar } from "../components/ButtonBar"; 6 | import { Card } from "../components/Card"; 7 | import { Name } from "../components/Name"; 8 | import { uid } from "../utils/uid"; 9 | import { SmallText } from "../components/SmallText"; 10 | import { Page } from "../components/Page"; 11 | import { Introduction } from "../components/Introduction"; 12 | 13 | type MyObject = { 14 | id: string; 15 | order: string; 16 | user: string; 17 | }; 18 | 19 | function sortListOnOrderKeyAndId(list: MyObject[]) { 20 | return list.sort((a, b) => { 21 | if (a.order < b.order) return -1; 22 | if (a.order > b.order) return 1; 23 | if (a.id < b.id) return -1; 24 | if (a.id > b.id) return 1; 25 | return 0; 26 | }); 27 | } 28 | 29 | const LATENCY = 3000; 30 | 31 | const initial = [{ id: uid(), user: "Initial", order: "a0" }]; 32 | 33 | export const Interleaving = () => { 34 | const [left, setLeft] = useState(initial); 35 | const [right, setRight] = useState(initial); 36 | const [syncing, setSyncing] = useState(false); 37 | 38 | const orderKeysLeft = left.map((item) => item.order); 39 | const leftGenerator = new IndexGenerator(orderKeysLeft); 40 | const orderKeysRight = right.map((item) => item.order); 41 | const rightGenerator = new IndexGenerator(orderKeysRight); 42 | const activeTimeouts = useRef(0); 43 | 44 | const addToLeft = (newObjects: MyObject[]) => { 45 | addToSourceAndRemoteWithLatency(newObjects, setLeft, setRight); 46 | }; 47 | 48 | const addToRight = (newObjects: MyObject[]) => { 49 | addToSourceAndRemoteWithLatency(newObjects, setRight, setLeft); 50 | }; 51 | 52 | const addToSourceAndRemoteWithLatency = ( 53 | newObjects: MyObject[], 54 | source: React.Dispatch>, 55 | remote: React.Dispatch> 56 | ) => { 57 | source((prev) => sortListOnOrderKeyAndId([...prev, ...newObjects])); 58 | activeTimeouts.current += 1; 59 | setSyncing(true); 60 | setTimeout(() => { 61 | remote((prev) => sortListOnOrderKeyAndId([...prev, ...newObjects])); 62 | activeTimeouts.current -= 1; 63 | if (activeTimeouts.current === 0) { 64 | setSyncing(false); 65 | } 66 | }, LATENCY); 67 | }; 68 | 69 | useEffect(() => {}, []); 70 | 71 | return ( 72 | <> 73 | 74 | Below is an example of two lists that sync with 3 second latency. You 75 | can see interleaving in action by clicking the buttons in both lists 76 | within 3 seconds of each other.
77 | In most apps interleaving is not a problem, but it can be a problem in 78 | for instance Text editors. 79 |
80 | 81 | 87 | 88 | 94 | 95 | 96 | ); 97 | }; 98 | 99 | interface InterleavingListProps { 100 | user: string; 101 | generator: IndexGenerator; 102 | list: MyObject[]; 103 | addObjects: (list: MyObject[]) => void; 104 | } 105 | 106 | const InterleavingList: React.FC = ({ 107 | generator, 108 | list, 109 | addObjects, 110 | user, 111 | }) => { 112 | const handleNBefore = (orderKey: string, n: number) => { 113 | addObjects( 114 | generator 115 | .nKeysBefore(orderKey, n) 116 | .map((order) => ({ id: uid(), user, order })) 117 | ); 118 | }; 119 | const handleNAfter = (orderKey: string, n: number) => { 120 | addObjects( 121 | generator 122 | .nKeysAfter(orderKey, n) 123 | .map((order) => ({ id: uid(), user, order })) 124 | ); 125 | }; 126 | 127 | return ( 128 | 129 |

{user}

130 |
    131 | {list.map((item) => { 132 | const backGroundColor = 133 | item.user === "User 1" 134 | ? "#CEFBC1" 135 | : item.user === "User 2" 136 | ? "#E9CBFE" 137 | : undefined; 138 | 139 | return ( 140 |
  1. 141 | 142 | 143 | {item.order} {item.user} 144 | 145 | 146 | 149 | 152 | 155 | 158 | 159 | 160 |
  2. 161 | ); 162 | })} 163 |
164 |
165 | ); 166 | }; 167 | 168 | interface SyncInditcatorProps { 169 | syncing: boolean; 170 | } 171 | 172 | const SyncIndicator: React.FC = ({ syncing }) => { 173 | return ( 174 |
{syncing ? "Syncing..." : "Synced"}
175 | ); 176 | }; 177 | -------------------------------------------------------------------------------- /examples/react/src/examples/interleavingWithoutJitter.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { List } from "../components/List"; 3 | import{ IndexGenerator } from "fractional-indexing-jittered"; 4 | import { Button } from "../components/Button"; 5 | import { ButtonBar } from "../components/ButtonBar"; 6 | import { Card } from "../components/Card"; 7 | import { Name } from "../components/Name"; 8 | import { uid } from "../utils/uid"; 9 | import { SmallText } from "../components/SmallText"; 10 | import { Page } from "../components/Page"; 11 | import { Introduction } from "../components/Introduction"; 12 | 13 | type MyObject = { 14 | id: string; 15 | order: string; 16 | user: string; 17 | }; 18 | 19 | function sortListOnOrderKeyAndId(list: MyObject[]) { 20 | return list.sort((a, b) => { 21 | if (a.order < b.order) return -1; 22 | if (a.order > b.order) return 1; 23 | if (a.id < b.id) return -1; 24 | if (a.id > b.id) return 1; 25 | return 0; 26 | }); 27 | } 28 | 29 | const LATENCY = 3000; 30 | 31 | const initial = [{ id: uid(), user: "Initial", order: "a0" }]; 32 | 33 | export const InterleavingWithoutJitter = () => { 34 | const [left, setLeft] = useState(initial); 35 | const [right, setRight] = useState(initial); 36 | const [syncing, setSyncing] = useState(false); 37 | 38 | const orderKeysLeft = left.map((item) => item.order); 39 | const leftGenerator = new IndexGenerator(orderKeysLeft, { useJitter: false }); 40 | const orderKeysRight = right.map((item) => item.order); 41 | const rightGenerator = new IndexGenerator(orderKeysRight, { useJitter: false }); 42 | const activeTimeouts = useRef(0); 43 | 44 | const addToLeft = (newObjects: MyObject[]) => { 45 | addToSourceAndRemoteWithLatency(newObjects, setLeft, setRight); 46 | }; 47 | 48 | const addToRight = (newObjects: MyObject[]) => { 49 | addToSourceAndRemoteWithLatency(newObjects, setRight, setLeft); 50 | }; 51 | 52 | const addToSourceAndRemoteWithLatency = ( 53 | newObjects: MyObject[], 54 | source: React.Dispatch>, 55 | remote: React.Dispatch> 56 | ) => { 57 | source((prev) => sortListOnOrderKeyAndId([...prev, ...newObjects])); 58 | activeTimeouts.current += 1; 59 | setSyncing(true); 60 | setTimeout(() => { 61 | remote((prev) => sortListOnOrderKeyAndId([...prev, ...newObjects])); 62 | activeTimeouts.current -= 1; 63 | if (activeTimeouts.current === 0) { 64 | setSyncing(false); 65 | } 66 | }, LATENCY); 67 | }; 68 | 69 | useEffect(() => {}, []); 70 | 71 | return ( 72 | <> 73 | 74 | Below is an example of two lists that sync with 3 second latency. You 75 | can see interleaving in action by clicking the buttons in both lists 76 | within 3 seconds of each other.
77 | In this example, we are not using jitter, so the keys will clash on interleave. 78 |
79 | 80 | 86 | 87 | 93 | 94 | 95 | ); 96 | }; 97 | 98 | interface InterleavingListProps { 99 | user: string; 100 | generator: IndexGenerator; 101 | list: MyObject[]; 102 | addObjects: (list: MyObject[]) => void; 103 | } 104 | 105 | const InterleavingList: React.FC = ({ 106 | generator, 107 | list, 108 | addObjects, 109 | user, 110 | }) => { 111 | const handleNBefore = (orderKey: string, n: number) => { 112 | addObjects( 113 | generator 114 | .nKeysBefore(orderKey, n) 115 | .map((order) => ({ id: uid(), user, order })) 116 | ); 117 | }; 118 | const handleNAfter = (orderKey: string, n: number) => { 119 | addObjects( 120 | generator 121 | .nKeysAfter(orderKey, n) 122 | .map((order) => ({ id: uid(), user, order })) 123 | ); 124 | }; 125 | 126 | return ( 127 | 128 |

{user}

129 |
    130 | {list.map((item) => { 131 | const backGroundColor = 132 | item.user === "User 1" 133 | ? "#CEFBC1" 134 | : item.user === "User 2" 135 | ? "#E9CBFE" 136 | : undefined; 137 | 138 | return ( 139 |
  1. 140 | 141 | 142 | {item.order} {item.user} 143 | 144 | 145 | 148 | 151 | 154 | 157 | 158 | 159 |
  2. 160 | ); 161 | })} 162 |
163 |
164 | ); 165 | }; 166 | 167 | interface SyncInditcatorProps { 168 | syncing: boolean; 169 | } 170 | 171 | const SyncIndicator: React.FC = ({ syncing }) => { 172 | return ( 173 |
{syncing ? "Syncing..." : "Synced"}
174 | ); 175 | }; 176 | -------------------------------------------------------------------------------- /examples/react/src/examples/memoizedGenerator.tsx: -------------------------------------------------------------------------------- 1 | import{ IndexGenerator } from "fractional-indexing-jittered"; 2 | import { memo, useCallback, useMemo, useRef, useState } from "react"; 3 | import { Button } from "../components/Button"; 4 | import { SmallText } from "../components/SmallText"; 5 | import { uid } from "../utils/uid"; 6 | import { ButtonBar } from "../components/ButtonBar"; 7 | import { Card } from "../components/Card"; 8 | import { Name } from "../components/Name"; 9 | 10 | type MyObject = { 11 | id: string; 12 | order: string; 13 | }; 14 | 15 | function sortListOnOrderKeyAndId(list: MyObject[]) { 16 | return list.sort((a, b) => { 17 | if (a.order < b.order) return -1; 18 | if (a.order > b.order) return 1; 19 | if (a.id < b.id) return -1; 20 | if (a.id > b.id) return 1; 21 | return 0; 22 | }); 23 | } 24 | 25 | export const MemoizeGenerator = () => { 26 | const [list, setList] = useState([]); 27 | 28 | // if you need to pass your callback functions to child component, 29 | // you can use `useCallback` to memoize the generator and the callback functions 30 | const generator = useMemo(() => { 31 | const initialOrder = list.map((item) => item.order); 32 | return new IndexGenerator(initialOrder); 33 | // we don't want to re-run this effect when the list changes 34 | // eslint-disable-next-line react-hooks/exhaustive-deps 35 | }, []); 36 | 37 | const addToList = useCallback( 38 | (orders: string[]) => { 39 | const items = orders.map((order) => ({ id: uid(), order })); 40 | setList((previousList) => { 41 | const newList = sortListOnOrderKeyAndId([...previousList, ...items]); 42 | generator.updateList(newList.map((item) => item.order)); 43 | return newList; 44 | }); 45 | }, 46 | [generator] 47 | ); 48 | 49 | const handlePrepend = useCallback(() => { 50 | addToList([generator.keyStart()]); 51 | }, [addToList, generator]); 52 | 53 | const handleAppend = useCallback(() => { 54 | addToList([generator.keyEnd()]); 55 | }, [addToList, generator]); 56 | 57 | const handleNBefore = useCallback( 58 | (orderKey: string, n: number) => { 59 | addToList(generator.nKeysBefore(orderKey, n)); 60 | }, 61 | [addToList, generator] 62 | ); 63 | 64 | const handleNAfter = useCallback( 65 | (orderKey: string, n: number) => { 66 | addToList(generator.nKeysAfter(orderKey, n)); 67 | }, 68 | [addToList, generator] 69 | ); 70 | 71 | return ( 72 |
73 | 74 | 75 |
    76 | {list.map((item) => ( 77 | 83 | ))} 84 |
85 |
86 | ); 87 | }; 88 | 89 | interface MomoizedCardProps { 90 | item: MyObject; 91 | handleNBefore: (orderKey: string, n: number) => void; 92 | handleNAfter: (orderKey: string, n: number) => void; 93 | } 94 | 95 | // we can use `memo` to memoize the rendering of the child component 96 | const WrappedCard = memo( 97 | ({ item, handleNBefore, handleNAfter }: MomoizedCardProps) => { 98 | const renderCount = useRef(0); 99 | renderCount.current += 1; 100 | 101 | return ( 102 |
  • 103 | 104 | 105 | {item.order} {' '} 106 | times rendered: {renderCount.current}{" "} 107 | 108 | 109 | 112 | 115 | 116 | 119 | 120 | 121 |
  • 122 | ); 123 | } 124 | ); 125 | -------------------------------------------------------------------------------- /examples/react/src/examples/simple.tsx: -------------------------------------------------------------------------------- 1 | import{ IndexGenerator } from "fractional-indexing-jittered"; 2 | import { useEffect, useState } from "react"; 3 | import { Button } from "../components/Button"; 4 | import { uid } from "../utils/uid"; 5 | import { Card } from "../components/Card"; 6 | import { Name } from "../components/Name"; 7 | import { List } from "../components/List"; 8 | import { ButtonBar } from "../components/ButtonBar"; 9 | 10 | type MyObject = { 11 | id: string; 12 | order: string; 13 | }; 14 | 15 | 16 | 17 | export const SimpleList = () => { 18 | const [list, setList] = useState([]); 19 | const orderKeys = list.map((item) => item.order); 20 | // creating a new generator on every render is not a big deal for simple lists 21 | const generator = new IndexGenerator(orderKeys); 22 | 23 | const addToList = (orders: string[]) => { 24 | const items = orders.map((order) => ({ id: uid(), order })); 25 | const newList = sortListOnOrderKeyAndId([...list, ...items]); 26 | setList(newList); 27 | }; 28 | 29 | const handlePrepend = () => { 30 | addToList([generator.keyStart()]); 31 | }; 32 | 33 | const handleAppend = () => { 34 | addToList([generator.keyEnd()]); 35 | }; 36 | 37 | const handleBefore = (orderKey: string) => { 38 | addToList([generator.keyBefore(orderKey)]); 39 | }; 40 | 41 | const handleNBefore = (orderKey: string, n: number) => { 42 | addToList(generator.nKeysBefore(orderKey, n)); 43 | }; 44 | 45 | const handleAfter = (orderKey: string) => { 46 | addToList([generator.keyAfter(orderKey)]); 47 | }; 48 | 49 | const handleNAfter = (orderKey: string, n: number) => { 50 | addToList(generator.nKeysAfter(orderKey, n)); 51 | }; 52 | 53 | // Fill the example with demo data 54 | useEffect(() => { 55 | addToList(generator.nKeysStart(3)); 56 | // eslint-disable-next-line react-hooks/exhaustive-deps 57 | }, []); 58 | 59 | return ( 60 | 61 | 62 | 63 |
      64 | {list.map((item) => ( 65 |
    1. 66 | 67 | {item.order} 68 | 69 | 72 | 75 | 76 | 79 | 80 | 81 |
    2. 82 | ))} 83 |
    84 |
    85 | ); 86 | }; 87 | 88 | function sortListOnOrderKeyAndId(list: MyObject[]) { 89 | return list.sort((a, b) => { 90 | if (a.order < b.order) return -1; 91 | if (a.order > b.order) return 1; 92 | if (a.id < b.id) return -1; 93 | if (a.id > b.id) return 1; 94 | return 0; 95 | }); 96 | } 97 | 98 | -------------------------------------------------------------------------------- /examples/react/src/examples/simpleListWithoutJitter.tsx: -------------------------------------------------------------------------------- 1 | import{ IndexGenerator } from "fractional-indexing-jittered"; 2 | import { useEffect, useState } from "react"; 3 | import { Button } from "../components/Button"; 4 | import { uid } from "../utils/uid"; 5 | import { Card } from "../components/Card"; 6 | import { Name } from "../components/Name"; 7 | import { List } from "../components/List"; 8 | import { ButtonBar } from "../components/ButtonBar"; 9 | 10 | type MyObject = { 11 | id: string; 12 | order: string; 13 | }; 14 | 15 | function sortListOnOrderKeyAndId(list: MyObject[]) { 16 | return list.sort((a, b) => { 17 | if (a.order < b.order) return -1; 18 | if (a.order > b.order) return 1; 19 | if (a.id < b.id) return -1; 20 | if (a.id > b.id) return 1; 21 | return 0; 22 | }); 23 | } 24 | 25 | export const SimpleListWithoutJitter = () => { 26 | const [list, setList] = useState([]); 27 | const orderKeys = list.map((item) => item.order); 28 | // creating a new generator on every render is not a big deal for simple lists 29 | const generator = new IndexGenerator(orderKeys, { useJitter: false }); 30 | 31 | const addToList = (orders: string[]) => { 32 | const items = orders.map((order) => ({ id: uid(), order })); 33 | const newList = sortListOnOrderKeyAndId([...list, ...items]); 34 | setList(newList); 35 | }; 36 | 37 | const handlePrepend = () => { 38 | addToList([generator.keyStart()]); 39 | }; 40 | 41 | const handleAppend = () => { 42 | addToList([generator.keyEnd()]); 43 | }; 44 | 45 | const handleBefore = (orderKey: string) => { 46 | addToList([generator.keyBefore(orderKey)]); 47 | }; 48 | 49 | const handleNBefore = (orderKey: string, n: number) => { 50 | addToList(generator.nKeysBefore(orderKey, n)); 51 | }; 52 | 53 | const handleAfter = (orderKey: string) => { 54 | addToList([generator.keyAfter(orderKey)]); 55 | }; 56 | 57 | const handleNAfter = (orderKey: string, n: number) => { 58 | addToList(generator.nKeysAfter(orderKey, n)); 59 | }; 60 | 61 | // Fill the example with demo data 62 | useEffect(() => { 63 | addToList(generator.nKeysStart(3)); 64 | // eslint-disable-next-line react-hooks/exhaustive-deps 65 | }, []); 66 | 67 | return ( 68 | 69 | 70 | 71 |
      72 | {list.map((item) => ( 73 |
    1. 74 | 75 | {item.order} 76 | 77 | 80 | 83 | 84 | 87 | 88 | 89 |
    2. 90 | ))} 91 |
    92 |
    93 | ); 94 | }; 95 | 96 | -------------------------------------------------------------------------------- /examples/react/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /examples/react/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import "./index.css"; 4 | import App from "./App"; 5 | import { HashRouter } from "react-router-dom"; 6 | 7 | const root = ReactDOM.createRoot( 8 | document.getElementById("root") as HTMLElement 9 | ); 10 | // We use the HashRouter here because we are using GitHub pages 11 | root.render( 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | -------------------------------------------------------------------------------- /examples/react/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/react/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/react/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /examples/react/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /examples/react/src/utils/uid.ts: -------------------------------------------------------------------------------- 1 | // should not be used in production 2 | export const uid = () => Math.random().toString(34).slice(2); 3 | -------------------------------------------------------------------------------- /examples/react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | transform: { 3 | "^.+\\.tsx?$": [ 4 | "ts-jest", 5 | { 6 | tsconfig: "tsconfig.esm.json", 7 | }, 8 | ], 9 | }, 10 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", 11 | testPathIgnorePatterns: ["/lib/", "/node_modules/", "examples"], 12 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 13 | collectCoverage: true, 14 | }; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fractional-indexing-jittered", 3 | "version": "1.0.0", 4 | "description": "Fractional index library with jittering and generator", 5 | "type": "module", 6 | "main": "./lib/index.cjs", 7 | "module": "./lib/index.js", 8 | "homepage": "https://github.com/TMeerhof/fractional-indexing-jittered", 9 | "repository": { 10 | "url": "git+https://github.com/TMeerhof/fractional-indexing-jittered.git", 11 | "type": "git" 12 | }, 13 | "scripts": { 14 | "clean": "rm -rf ./lib", 15 | "prepack": "npm run build", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "build": "npm run clean && tsup src/index.ts --format cjs,esm --dts --out-dir lib" 19 | }, 20 | "exports": { 21 | ".": { 22 | "import": { 23 | "types": "./lib/index.d.ts", 24 | "default": "./lib/index.js" 25 | }, 26 | "require": { 27 | "types": "./lib/index.d.cts", 28 | "default": "./lib/index.cjs" 29 | } 30 | } 31 | }, 32 | "author": "https://github.com/TMeerhof", 33 | "license": "CC0-1.0", 34 | "devDependencies": { 35 | "@types/jest": "^29.5.8", 36 | "jest": "^29.7.0", 37 | "ts-jest": "^29.1.1", 38 | "tsup": "^8.0.1", 39 | "typescript": "^5.2.2" 40 | }, 41 | "keywords": [ 42 | "fractional", 43 | "indexing", 44 | "ordering", 45 | "order", 46 | "jitter" 47 | ], 48 | "files": [ 49 | "lib/**/*" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /src/IndexGenerator.spec.ts: -------------------------------------------------------------------------------- 1 | import { IndexGenerator } from "./IndexGenerator"; // Adjust this import according to your project structure 2 | 3 | describe("Basic Generator", () => { 4 | let generator: IndexGenerator; 5 | beforeAll(() => { 6 | generator = new IndexGenerator([], { useJitter: false }); 7 | }); 8 | 9 | it("is should generate correct keys for a empty list", () => { 10 | const key1 = generator.keyStart(); 11 | expect(key1).toBe("a0"); 12 | const key2 = generator.keyEnd(); 13 | expect(key2).toBe("a0"); 14 | const keysStart = generator.nKeysStart(2); 15 | expect(keysStart).toStrictEqual(["a0", "a1"]); 16 | const keysEnd = generator.nKeysEnd(2); 17 | expect(keysEnd).toStrictEqual(["a0", "a1"]); 18 | }); 19 | 20 | it("is should generate correct start keys for populated list", () => { 21 | generator.updateList(["a0"]); 22 | const key = generator.keyStart(); 23 | const keys = generator.nKeysStart(2); 24 | expect(key).toBe("Zz"); 25 | expect(keys).toStrictEqual(["Zy", "Zz"]); 26 | }); 27 | 28 | it("is should generate correct end keys for populated list", () => { 29 | generator.updateList(["a1"]); 30 | const key = generator.keyEnd(); 31 | const keys = generator.nKeysEnd(2); 32 | expect(key).toBe("a2"); 33 | expect(keys).toStrictEqual(["a2", "a3"]); 34 | }); 35 | 36 | it("is should generate correct keys after if last item", () => { 37 | generator.updateList(["a1"]); 38 | const key = generator.keyAfter("a1"); 39 | const keys = generator.nKeysAfter("a1", 2); 40 | expect(key).toBe("a2"); 41 | expect(keys).toStrictEqual(["a2", "a3"]); 42 | }); 43 | 44 | it("is should generate correct keys after not last item", () => { 45 | generator.updateList(["a1", "a2"]); 46 | const key = generator.keyAfter("a1"); 47 | const keys = generator.nKeysAfter("a1", 3); 48 | expect(key).toBe("a1V"); 49 | expect(keys).toStrictEqual(["a1F", "a1V", "a1k"]); 50 | }); 51 | 52 | it("is should generate correct keys before if first item", () => { 53 | generator.updateList(["a5"]); 54 | const key = generator.keyBefore("a5"); 55 | const keys = generator.nKeysBefore("a5", 2); 56 | expect(key).toBe("a4"); 57 | expect(keys).toStrictEqual(["a3", "a4"]); 58 | }); 59 | 60 | it("is should generate correct keys after not last item", () => { 61 | generator.updateList(["a1", "a2"]); 62 | const key = generator.keyAfter("a1"); 63 | const keys = generator.nKeysAfter("a1", 3); 64 | expect(key).toBe("a1V"); 65 | expect(keys).toStrictEqual(["a1F", "a1V", "a1k"]); 66 | }); 67 | 68 | it("is should generate correct keys before if not first item", () => { 69 | generator.updateList(["a1", "a2"]); 70 | const key = generator.keyBefore("a2"); 71 | const keys = generator.nKeysBefore("a2", 3); 72 | expect(key).toBe("a1V"); 73 | expect(keys).toStrictEqual(["a1F", "a1V", "a1k"]); 74 | }); 75 | }); 76 | 77 | describe("Jittered Generator", () => { 78 | let generator: IndexGenerator; 79 | // We need to mock Math.random() to get consistent results 80 | // 0.5 * default Jitter range === '6CO' 81 | beforeAll(() => { 82 | jest.spyOn(global.Math, "random").mockReturnValue(0.5); 83 | generator = new IndexGenerator([], { useJitter: true }); 84 | }); 85 | 86 | afterAll(() => { 87 | jest.spyOn(global.Math, "random").mockRestore(); 88 | }); 89 | 90 | it("is should generate correct jittered keys", () => { 91 | const keys = generator.nKeysStart(3); 92 | expect(keys).toStrictEqual(["a06CO", "a16CO", "a26CO"]); 93 | }); 94 | }); 95 | 96 | describe("Group Generator", () => { 97 | let generator: IndexGenerator; 98 | beforeAll(() => { 99 | generator = new IndexGenerator([], { useJitter: false, groupIdLength: 2 }); 100 | }); 101 | it("is should generate correct keys for a empty list", () => { 102 | const key1 = generator.keyStart("g1"); 103 | expect(key1).toBe("g1a0"); 104 | const key2 = generator.keyEnd("g1"); 105 | expect(key2).toBe("g1a0"); 106 | const keysStart = generator.nKeysStart(2, "g1"); 107 | expect(keysStart).toStrictEqual(["g1a0", "g1a1"]); 108 | const keysEnd = generator.nKeysEnd(2, "g1"); 109 | expect(keysEnd).toStrictEqual(["g1a0", "g1a1"]); 110 | }); 111 | 112 | it("should throw if groupId is not supplied", () => { 113 | expect(() => generator.keyStart()).toThrow(); 114 | }); 115 | 116 | it("should throw if groupId is incorrect length", () => { 117 | expect(() => generator.keyStart("group1")).toThrow(); 118 | }); 119 | 120 | it("is should generate correct start keys for populated list", () => { 121 | generator.updateList(["g1a0"]); 122 | const key = generator.keyStart("g1"); 123 | const keys = generator.nKeysStart(2, "g1"); 124 | expect(key).toBe("g1Zz"); 125 | expect(keys).toStrictEqual(["g1Zy", "g1Zz"]); 126 | }); 127 | 128 | it("is should generate correct end keys for populated list", () => { 129 | generator.updateList(["g1a1"]); 130 | const key = generator.keyEnd("g1"); 131 | const keys = generator.nKeysEnd(2, "g1"); 132 | expect(key).toBe("g1a2"); 133 | expect(keys).toStrictEqual(["g1a2", "g1a3"]); 134 | }); 135 | 136 | it("is should generate correct keys after if last item", () => { 137 | generator.updateList(["g1a1"]); 138 | const key = generator.keyAfter("g1a1"); 139 | const keys = generator.nKeysAfter("g1a1", 2); 140 | expect(key).toBe("g1a2"); 141 | expect(keys).toStrictEqual(["g1a2", "g1a3"]); 142 | }); 143 | 144 | it("is should generate correct keys after not last item", () => { 145 | generator.updateList(["g1a1", "g1a2"]); 146 | const key = generator.keyAfter("g1a1"); 147 | const keys = generator.nKeysAfter("g1a1", 3); 148 | expect(key).toBe("g1a1V"); 149 | expect(keys).toStrictEqual(["g1a1F", "g1a1V", "g1a1k"]); 150 | }); 151 | 152 | it("is should generate correct keys before if first item", () => { 153 | generator.updateList(["g1a5"]); 154 | const key = generator.keyBefore("g1a5"); 155 | const keys = generator.nKeysBefore("g1a5", 2); 156 | expect(key).toBe("g1a4"); 157 | expect(keys).toStrictEqual(["g1a3", "g1a4"]); 158 | }); 159 | 160 | it("is should generate correct keys after not last item", () => { 161 | generator.updateList(["g1a1", "g1a2"]); 162 | const key = generator.keyAfter("g1a1"); 163 | const keys = generator.nKeysAfter("g1a1", 3); 164 | expect(key).toBe("g1a1V"); 165 | expect(keys).toStrictEqual(["g1a1F", "g1a1V", "g1a1k"]); 166 | }); 167 | 168 | it("is should generate correct keys before if not first item", () => { 169 | generator.updateList(["g1a1", "g1a2"]); 170 | const key = generator.keyBefore("g1a2"); 171 | const keys = generator.nKeysBefore("g1a2", 3); 172 | expect(key).toBe("g1a1V"); 173 | expect(keys).toStrictEqual(["g1a1F", "g1a1V", "g1a1k"]); 174 | }); 175 | 176 | it("is should generate correct new start key for populated list and different group", () => { 177 | generator.updateList(["g1a5"]); 178 | const key1 = generator.keyStart("g2"); 179 | expect(key1).toBe("g2a0"); 180 | }); 181 | }); 182 | -------------------------------------------------------------------------------- /src/IndexGenerator.ts: -------------------------------------------------------------------------------- 1 | import { IndexedCharacterSet, base62CharSet } from "./charSet"; 2 | import { 3 | generateNJitteredKeysBetween, 4 | generateNKeysBetween, 5 | } from "./generateKeyBetween"; 6 | 7 | export interface GeneratorOptions { 8 | charSet?: IndexedCharacterSet; 9 | useJitter?: boolean; 10 | groupIdLength?: number; 11 | } 12 | 13 | export class IndexGenerator { 14 | private charSet: IndexedCharacterSet; 15 | private useJitter: boolean; 16 | private list: string[]; 17 | private useGroups: boolean; 18 | private groupIdLength: number; 19 | 20 | constructor(list: string[], options: GeneratorOptions = {}) { 21 | this.charSet = options.charSet ?? base62CharSet(); 22 | this.useJitter = options.useJitter ?? true; 23 | this.list = list; 24 | this.useGroups = !!options.groupIdLength && options.groupIdLength > 0; 25 | this.groupIdLength = options.groupIdLength ?? 0; 26 | } 27 | 28 | /** 29 | * Updates the list that the generator uses to generate keys. 30 | * The generator will not mutate the internal list when generating keys. 31 | */ 32 | public updateList(list: string[]) { 33 | this.list = [...list].sort(); 34 | } 35 | 36 | /** 37 | * Generate any number of keys at the start of the list (before the first key). 38 | * Optionally you can supply a groupId to generate keys at the start of a specific group. 39 | */ 40 | public nKeysStart(n: number, groupId?: string): string[] { 41 | this.validateGroupId(groupId); 42 | return this.generateNKeysBetween( 43 | null, 44 | this.firstOfGroup(groupId), 45 | n, 46 | groupId 47 | ); 48 | } 49 | 50 | /** 51 | * Generate a single key at the start of the list (before the first key). 52 | * Optionally you can supply a groupId to generate a key at the start of a specific group. 53 | */ 54 | public keyStart(groupId?: string): string { 55 | this.validateGroupId(groupId); 56 | return this.nKeysStart(1, groupId)[0]; 57 | } 58 | 59 | /** 60 | * Generate any number of keys at the end of the list (after the last key). 61 | * Optionally you can supply a groupId to generate keys at the end of a specific group. 62 | */ 63 | public nKeysEnd(n: number, groupId?: string): string[] { 64 | this.validateGroupId(groupId); 65 | return this.generateNKeysBetween( 66 | this.lastOfGroup(groupId), 67 | null, 68 | n, 69 | groupId 70 | ); 71 | } 72 | 73 | /** 74 | * Generate a single key at the end of the list (after the last key). 75 | * Optionally you can supply a groupId to generate a key at the end of a specific group. 76 | */ 77 | public keyEnd(groupId?: string): string { 78 | this.validateGroupId(groupId); 79 | return this.nKeysEnd(1, groupId)[0]; 80 | } 81 | 82 | /** 83 | * Generate any number of keys behind a specific key and in front of the next key. 84 | * GroupId will be inferred from the orderKey if working with groups 85 | */ 86 | public nKeysAfter(orderKey: string, n: number): string[] { 87 | const keyAfter = this.getKeyAfter(orderKey); 88 | return this.generateNKeysBetween( 89 | orderKey, 90 | keyAfter, 91 | n, 92 | this.groupId(orderKey) 93 | ); 94 | } 95 | 96 | /** 97 | * Generate a single key behind a specific key and in front of the next key. 98 | * GroupId will be inferred from the orderKey if working with groups 99 | */ 100 | public keyAfter(orderKey: string): string { 101 | return this.nKeysAfter(orderKey, 1)[0]; 102 | } 103 | 104 | /** 105 | * Generate any number of keys in front of a specific key and behind the previous key. 106 | * GroupId will be inferred from the orderKey if working with groups 107 | */ 108 | public nKeysBefore(orderKey: string, n: number): string[] { 109 | const keyBefore = this.getKeyBefore(orderKey); 110 | return this.generateNKeysBetween( 111 | keyBefore, 112 | orderKey, 113 | n, 114 | this.groupId(orderKey) 115 | ); 116 | } 117 | 118 | /** 119 | * Generate a single key in front of a specific key and behind the previous key. 120 | * GroupId will be inferred from the orderKey if working with groups 121 | */ 122 | public keyBefore(orderKey: string): string { 123 | return this.nKeysBefore(orderKey, 1)[0]; 124 | } 125 | 126 | /** 127 | * private function responsible for calling the correct generate function 128 | */ 129 | private generateNKeysBetween( 130 | lowerKey: string | null, 131 | upperKey: string | null, 132 | n: number, 133 | groupId: string | undefined 134 | ): string[] { 135 | const lower = this.groupLessKey(lowerKey); 136 | const upper = this.groupLessKey(upperKey); 137 | const keys = this.useJitter 138 | ? generateNJitteredKeysBetween(lower, upper, n, this.charSet) 139 | : generateNKeysBetween(lower, upper, n, this.charSet); 140 | return !groupId ? keys : keys.map((key) => groupId + key); 141 | } 142 | 143 | /** 144 | * get the key before the supplied orderKey, if it exists and is in the same group 145 | */ 146 | private getKeyBefore(orderKey: string): string | null { 147 | const index = this.list.indexOf(orderKey); 148 | if (index === -1) { 149 | throw new Error(`orderKey is not in the list`); 150 | } 151 | const before = this.list[index - 1]; 152 | return !!before && this.isSameGroup(orderKey, before) ? before : null; 153 | } 154 | 155 | /** 156 | * get the key after the supplied orderKey, if it exists and is in the same group 157 | */ 158 | private getKeyAfter(orderKey: string): string | null { 159 | const index = this.list.indexOf(orderKey); 160 | if (index === -1) { 161 | throw new Error(`orderKey is not in the list`); 162 | } 163 | const after = this.list[index + 1]; 164 | return !!after && this.isSameGroup(orderKey, after) ? after : null; 165 | } 166 | 167 | /** 168 | * get the first key of the group (or the first key of the list if not using groups) 169 | */ 170 | private firstOfGroup(groupId: string | undefined): string | null { 171 | if (!this.useGroups) return this.list[0] ?? null; 172 | const first = this.list.find((key) => this.isPartOfGroup(key, groupId)); 173 | return first ?? null; 174 | } 175 | 176 | /** 177 | * get the last key of the group (or the last key of the list if not using groups) 178 | */ 179 | private lastOfGroup(groupId: string | undefined): string | null { 180 | if (!this.useGroups) return this.list[this.list.length - 1] ?? null; 181 | const allGroupItems = this.list.filter((key) => 182 | this.isPartOfGroup(key, groupId) 183 | ); 184 | const last = allGroupItems[allGroupItems.length - 1]; 185 | return last ?? null; 186 | } 187 | 188 | /** 189 | * throw an error if the groupId is invalid or supplied when not using groups 190 | */ 191 | private validateGroupId(groupId: string | undefined) { 192 | if (!this.useGroups) { 193 | if (groupId) { 194 | console.warn("groupId should not used when not using groups"); 195 | } 196 | return; 197 | } 198 | if (!groupId) { 199 | throw new Error("groupId is required when using groups"); 200 | } 201 | if (groupId.length !== this.groupIdLength) { 202 | throw new Error(`groupId must be the lenght supplied in the options`); 203 | } 204 | } 205 | 206 | /** 207 | * get the groupId from the orderKey 208 | */ 209 | private groupId(orderKey: string): string | undefined { 210 | if (!this.useGroups) return undefined; 211 | return this.splitIntoGroupIdAndOrderKey(orderKey)[0]; 212 | } 213 | 214 | /** 215 | * remove the groupId from the orderKey 216 | */ 217 | private groupLessKey(orderKey: string | null): string | null { 218 | if (!this.useGroups) return orderKey; 219 | return this.splitIntoGroupIdAndOrderKey(orderKey)[1]; 220 | } 221 | 222 | /** 223 | * split the orderKey into groupId and key 224 | * if not using groups, orderKey will be the same as key 225 | */ 226 | private splitIntoGroupIdAndOrderKey( 227 | orderKey: string | null 228 | ): [string | undefined, string | null] { 229 | if (!this.useGroups || !orderKey) { 230 | return [undefined, orderKey]; 231 | } 232 | const groupId = orderKey.substring(0, this.groupIdLength); 233 | const key = orderKey.substring(this.groupIdLength); 234 | return [groupId, key]; 235 | } 236 | 237 | /** 238 | * check if two keys are in the same group 239 | * if not using groups, keys will always be in the same group 240 | */ 241 | private isSameGroup(a: string, b: string): boolean { 242 | if (!this.useGroups) return true; 243 | const [aGroupId] = this.splitIntoGroupIdAndOrderKey(a); 244 | const [bGroupId] = this.splitIntoGroupIdAndOrderKey(b); 245 | return aGroupId === bGroupId; 246 | } 247 | 248 | /** 249 | * check if the key is part of the group 250 | * if not using groups, key will always be part of the group 251 | */ 252 | private isPartOfGroup(orderKey: string, groupId?: string): boolean { 253 | if (!this.useGroups) return true; 254 | const [keyGroupId] = this.splitIntoGroupIdAndOrderKey(orderKey); 255 | return keyGroupId === groupId; 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/__tests__/bruteForce.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | generateJitteredKeyBetween, 3 | generateKeyBetween, 4 | generateNJitteredKeysBetween, 5 | generateNKeysBetween, 6 | } from "../generateKeyBetween"; 7 | 8 | describe("brute force", () => { 9 | it("should generate keys lots of keys and keep them ordered", () => { 10 | const list = generateNJitteredKeysBetween(null, null, 1000); 11 | // insert a substantial amount of keys in the middle 12 | list.splice( 13 | 601, 14 | 0, 15 | ...generateNJitteredKeysBetween(list[600], list[601], 1000) 16 | ); 17 | // insert even more keys inside that block 18 | list.splice( 19 | 801, 20 | 0, 21 | ...generateNJitteredKeysBetween(list[800], list[801], 1000) 22 | ); 23 | expect(list.length).toBe(3000); 24 | expect([...list].sort()).toStrictEqual(list); 25 | }); 26 | it("should be able to insert on the same position a lot off times", () => { 27 | const list = generateNKeysBetween(null, null, 3); 28 | for (let i = 0; i < 1000; i++) { 29 | const newKey = generateKeyBetween(list[0], list[1]); 30 | list.splice(1, 0, newKey); 31 | } 32 | expect(list.length).toBe(1003); 33 | expect([...list].sort()).toStrictEqual(list); 34 | }); 35 | it("should be able to insert on the same position a lot off times", () => { 36 | const list = generateNJitteredKeysBetween(null, null, 3); 37 | for (let i = 0; i < 1000; i++) { 38 | const newKey = generateJitteredKeyBetween(list[0], list[1]); 39 | list.splice(1, 0, newKey); 40 | } 41 | expect(list.length).toBe(1003); 42 | expect([...list].sort()).toStrictEqual(list); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/__tests__/readme.spec.ts: -------------------------------------------------------------------------------- 1 | import { IndexGenerator } from "../IndexGenerator"; 2 | import { indexCharacterSet } from "../charSet"; 3 | import { 4 | generateJitteredKeyBetween, 5 | generateKeyBetween, 6 | generateNJitteredKeysBetween, 7 | } from "../generateKeyBetween"; 8 | 9 | describe("readme", () => { 10 | it("should run generator example without errors", () => { 11 | const generator = new IndexGenerator([]); 12 | 13 | // dummy code, would normally be stored in database or CRDT and updated from there 14 | const list: string[] = []; 15 | function updateList(newKey: string) { 16 | list.push(newKey); 17 | generator.updateList(list); 18 | } 19 | 20 | const first = generator.keyStart(); // "a01TB" a0 with jitter 21 | updateList(first); 22 | 23 | const second = generator.keyEnd(); // "a10Vt" a1 with jitter 24 | updateList(second); 25 | 26 | const firstAndHalf = generator.keyAfter(first); // "a0fMq" midpoint between firstKey and secondKey 27 | updateList(firstAndHalf); 28 | 29 | const firstAndQuarter = generator.keyBefore(firstAndHalf); // "a0M3o" midpoint between firstKey and keyInBetween 30 | updateList(firstAndQuarter); 31 | 32 | // [ 'a01TB', 'a0M3o', 'a0fMq', 'a10Vt' ] 33 | // [ first, firstAndHalf, firstAndQuarter, second ] 34 | // console.log(list.sort()); 35 | }); 36 | 37 | it("should run generator group code without errors", () => { 38 | // Jitter is disabled for this example to make the output more readable, but should be preferred in production 39 | const generator = new IndexGenerator([], { 40 | useJitter: false, 41 | groupIdLength: 2, 42 | }); 43 | 44 | const list: string[] = []; 45 | // dummy code, would normally be stored in database or CRDT and updated from there 46 | function updateList(orderKey: string) { 47 | list.push(orderKey); 48 | generator.updateList(list); 49 | } 50 | 51 | // same length as groupIdLength 52 | const group1 = "g1"; 53 | const group2 = "g2"; 54 | 55 | // "g1a0" group1 and first key 56 | const first = generator.keyStart(group1); 57 | updateList(first); 58 | 59 | // "g1a1" group1 and first key 60 | const second = generator.keyEnd(group1); 61 | updateList(second); 62 | 63 | // "g1a0V" midpoint between first and second 64 | const firstAndAHalf = generator.keyAfter(first); 65 | updateList(firstAndAHalf); 66 | 67 | // "g2a0" group2 and first key 68 | const firstGroup2 = generator.keyStart(group2); 69 | updateList(firstGroup2); 70 | 71 | // ["g1a0", "g1a0V", "g1a1", "g2a0"] 72 | // [ first, firstAndAHalf, second, firstGroup2 ] 73 | // console.log(list.sort()); 74 | }); 75 | 76 | it("run generateJitteredKeyBetween", () => { 77 | const first = generateJitteredKeyBetween(null, null); // "a090d" 78 | 79 | // Insert after 1st 80 | const second = generateJitteredKeyBetween(first, null); // "a1C1i" 81 | 82 | // Insert after 2nd 83 | const third = generateJitteredKeyBetween(second, null); // "a28hy" 84 | 85 | // Insert before 1st 86 | const zeroth = generateJitteredKeyBetween(null, first); // "ZzBYL" 87 | 88 | // Insert in between 2nd and 3rd (midpoint) 89 | const secondAndHalf = generateJitteredKeyBetween(second, third); // "a1kek" 90 | 91 | // console.log(first, second, third, zeroth, secondAndHalf); 92 | }); 93 | 94 | it("run generateJitteredKeyBetween", () => { 95 | const first = generateNJitteredKeysBetween(null, null, 2); // ['a061p', 'a18Ev'] 96 | 97 | // Insert two keys after 2nd 98 | generateNJitteredKeysBetween(first[1], null, 2); // ['a23WQ', 'a315m'] 99 | 100 | // Insert two keys before 1st 101 | generateNJitteredKeysBetween(null, first[0], 2); // ['Zy6Gx', 'ZzB7s'] 102 | 103 | // Insert two keys in between 1st and 2nd (midpoints) 104 | // generateNJitteredKeysBetween(second, third, 2); // ['a0SIA', 'a0iDa'] 105 | }); 106 | 107 | it("run indexCharacterSet", () => { 108 | const base90Set = indexCharacterSet({ 109 | chars: 110 | "!#$%&()*+,./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`abcdefghijklmnopqrstuvwxyz{|}~", 111 | }); 112 | 113 | const first = generateKeyBetween(null, null, base90Set); // 'Q!' 114 | 115 | // Insert after 1st 116 | const second = generateKeyBetween(first, null, base90Set); // 'Q#' 117 | 118 | // Insert in between 2nd and 3rd (midpoint) 119 | const firstAndHalf = generateKeyBetween(first, second, base90Set); // 'Q!Q' 120 | 121 | // Jittering is still recommended to avoid collisions 122 | const jitteredStart = generateNJitteredKeysBetween( 123 | null, 124 | null, 125 | 2, 126 | base90Set 127 | ); // [ 'Q!$i8', 'Q#.f}' ] 128 | 129 | // console.log(base90Set.jitterRange); // 145800 (so 3 times less likely to collide than base62) 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /src/charSet.spec.ts: -------------------------------------------------------------------------------- 1 | import { createCharSetDicts, integerLimits, validateChars } from "./charSet"; 2 | 3 | describe("validateCharSet", () => { 4 | it("should return true for valid charSet", () => { 5 | expect(() => validateChars("0123456")).not.toThrow(); 6 | }); 7 | 8 | it("should throw for to short a charSet", () => { 9 | expect(() => validateChars("012345")).toThrow(); 10 | }); 11 | 12 | it("should throw a unsorted charSet", () => { 13 | expect(() => validateChars("1234560")).toThrow(); 14 | }); 15 | }); 16 | 17 | describe("createCharSetDicts", () => { 18 | it("should return the correct index", () => { 19 | const { byChar } = createCharSetDicts("0123456"); 20 | expect(byChar["0"]).toBe(0); 21 | expect(byChar["1"]).toBe(1); 22 | expect(byChar["2"]).toBe(2); 23 | expect(byChar["3"]).toBe(3); 24 | expect(byChar["4"]).toBe(4); 25 | expect(byChar["5"]).toBe(5); 26 | expect(byChar["6"]).toBe(6); 27 | }); 28 | 29 | it("should return the correct char", () => { 30 | const { byCode } = createCharSetDicts("0123456"); 31 | expect(byCode[0]).toBe("0"); 32 | expect(byCode[1]).toBe("1"); 33 | expect(byCode[2]).toBe("2"); 34 | expect(byCode[3]).toBe("3"); 35 | expect(byCode[4]).toBe("4"); 36 | expect(byCode[5]).toBe("5"); 37 | expect(byCode[6]).toBe("6"); 38 | }); 39 | }); 40 | 41 | describe("integerLimits", () => { 42 | it("should return the correct distances", () => { 43 | const result = integerLimits(createCharSetDicts("01234567")); 44 | expect(result.firstPositive).toBe("4"); 45 | expect(result.mostPositive).toBe("7"); 46 | expect(result.mostNegative).toBe("0"); 47 | }); 48 | it("should throw for invalid params", () => { 49 | expect(() => integerLimits(createCharSetDicts("01234567"), "A")).toThrow(); 50 | }); 51 | it("should throw for invalid neutral", () => { 52 | expect(() => integerLimits(createCharSetDicts("01234567"), "7")).toThrow(); 53 | }); 54 | it("should throw for invalid mostPositive", () => { 55 | expect(() => 56 | integerLimits(createCharSetDicts("01234567"), "4", "6") 57 | ).toThrow(); 58 | }); 59 | it("should throw for invalid mostNegative", () => { 60 | expect(() => 61 | integerLimits(createCharSetDicts("01234567"), "3", "6", "7") 62 | ).toThrow(); 63 | }); 64 | it("should throw for mostPositive too close to neutral", () => { 65 | expect(() => 66 | integerLimits(createCharSetDicts("01234567"), "3", "4") 67 | ).toThrow(); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/charSet.ts: -------------------------------------------------------------------------------- 1 | export interface IndexCharacterSetOptions { 2 | chars: string; // sorted string of unique characters like "0123456789ABC" 3 | jitterRange?: number; // default is 1/5 of the total range created by adding 3 characters 4 | firstPositive?: string; // default is the middle character 5 | mostPositive?: string; // default is the last character 6 | mostNegative?: string; // default is the first character 7 | } 8 | 9 | export interface IndexedCharacterSet { 10 | chars: string; 11 | byChar: Record; 12 | byCode: Record; 13 | paddingDict: Record; 14 | length: number; 15 | first: string; 16 | last: string; 17 | firstPositive: string; 18 | mostPositive: string; 19 | firstNegative: string; 20 | mostNegative: string; 21 | jitterRange: number; 22 | } 23 | 24 | export function indexCharacterSet( 25 | options: IndexCharacterSetOptions 26 | ): IndexedCharacterSet { 27 | const dicts = createCharSetDicts(options.chars); 28 | const limits = integerLimits( 29 | dicts, 30 | options.firstPositive, 31 | options.mostPositive, 32 | options.mostNegative 33 | ); 34 | // 1/5 of the total range if we add 3 characters, TODO: feels a bit arbitrary and could be improved 35 | const jitterRange = 36 | options.jitterRange ?? Math.floor(Math.pow(dicts.length, 3) / 5); 37 | 38 | const paddingRange = paddingDict(jitterRange, dicts.length); 39 | 40 | return { 41 | chars: options.chars, 42 | byChar: dicts.byChar, 43 | byCode: dicts.byCode, 44 | length: dicts.length, 45 | first: dicts.byCode[0], 46 | last: dicts.byCode[dicts.length - 1], 47 | firstPositive: limits.firstPositive, 48 | mostPositive: limits.mostPositive, 49 | firstNegative: limits.firstNegative, 50 | mostNegative: limits.mostNegative, 51 | jitterRange, 52 | paddingDict: paddingRange, 53 | }; 54 | } 55 | 56 | export function validateChars(characters: string): void { 57 | if (characters.length < 7) { 58 | throw new Error("charSet must be at least 7 characters long"); 59 | } 60 | const chars = characters.split(""); 61 | const sorted = chars.sort(); 62 | const isEqual = sorted.join("") === characters; 63 | if (!isEqual) { 64 | throw new Error("charSet must be sorted"); 65 | } 66 | } 67 | 68 | type CharSetDicts = ReturnType; 69 | export function createCharSetDicts(charSet: string) { 70 | const byCode: Record = {}; 71 | const byChar: Record = {}; 72 | const length = charSet.length; 73 | 74 | for (let i = 0; i < length; i++) { 75 | const char = charSet[i]; 76 | byCode[i] = char; 77 | byChar[char] = i; 78 | } 79 | return { 80 | byCode: byCode, 81 | byChar: byChar, 82 | length: length, 83 | }; 84 | } 85 | 86 | export type IntegerLimits = ReturnType; 87 | export function integerLimits( 88 | dicts: CharSetDicts, 89 | firstPositive?: string, 90 | mostPositive?: string, 91 | mostNegative?: string 92 | ) { 93 | const firstPositiveIndex = firstPositive 94 | ? dicts.byChar[firstPositive] 95 | : Math.ceil(dicts.length / 2); 96 | const mostPositiveIndex = mostPositive 97 | ? dicts.byChar[mostPositive] 98 | : dicts.length - 1; 99 | const mostNegativeIndex = mostNegative ? dicts.byChar[mostNegative] : 0; 100 | if ( 101 | firstPositiveIndex === undefined || 102 | mostPositiveIndex === undefined || 103 | mostNegativeIndex === undefined 104 | ) { 105 | throw new Error("invalid charSet"); 106 | } 107 | if (mostPositiveIndex - firstPositiveIndex < 3) { 108 | throw new Error( 109 | "mostPositive must be at least 3 characters away from neutral" 110 | ); 111 | } 112 | if (firstPositiveIndex - mostNegativeIndex < 3) { 113 | throw new Error( 114 | "mostNegative must be at least 3 characters away from neutral" 115 | ); 116 | } 117 | 118 | return { 119 | firstPositive: dicts.byCode[firstPositiveIndex], 120 | mostPositive: dicts.byCode[mostPositiveIndex], 121 | firstNegative: dicts.byCode[firstPositiveIndex - 1], 122 | mostNegative: dicts.byCode[mostNegativeIndex], 123 | }; 124 | } 125 | 126 | // cache calculated distance for performance, TODO: is this needed? 127 | export function paddingDict(jitterRange: number, charSetLength: number) { 128 | const paddingDict: Record = {}; 129 | let distance = 0; 130 | for (let i = 0; i < 100; i++) { 131 | paddingDict[i] = Math.pow(charSetLength, i); 132 | if (paddingDict[i] > jitterRange) { 133 | break; 134 | } 135 | } 136 | return paddingDict; 137 | } 138 | 139 | // cache the base62 charSet since it's the default 140 | let _base62CharSet: IndexedCharacterSet | null = null; 141 | 142 | export function base62CharSet(): IndexedCharacterSet { 143 | if (_base62CharSet) return _base62CharSet; 144 | return (_base62CharSet = indexCharacterSet({ 145 | // Base62 are all the alphanumeric characters, database and user friendly 146 | // For shorter strings and more room you could opt for more characters 147 | chars: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 148 | // This gives us nice human readable keys to start with a0 a1 etc 149 | firstPositive: "a", 150 | mostPositive: "z", 151 | mostNegative: "A", 152 | })); 153 | } 154 | -------------------------------------------------------------------------------- /src/generateKeyBetween.spec.ts: -------------------------------------------------------------------------------- 1 | import { base62CharSet } from "./charSet"; 2 | import { 3 | generateJitteredKeyBetween, 4 | generateKeyBetween, 5 | generateNJitteredKeysBetween, 6 | generateNKeysBetween, 7 | } from "./generateKeyBetween"; 8 | 9 | // We need to mock Math.random() to get consistent results 10 | // 0.5 * default Jitter range === '6CO' 11 | beforeAll(() => { 12 | jest.spyOn(global.Math, "random").mockReturnValue(0.5); 13 | }); 14 | 15 | afterAll(() => { 16 | jest.spyOn(global.Math, "random").mockRestore(); 17 | }); 18 | 19 | describe("generateKeyBetween", () => { 20 | const charSet = base62CharSet(); 21 | it.each([ 22 | // a, expected, b 23 | [null, "a0", null], 24 | [null, "a0", "a1"], 25 | [null, "Zz", "a0"], 26 | [null, "b0S", "b0T"], 27 | ["b0S", "b0T", null], 28 | ["a0", "a4", "a8"], 29 | ["a0", "a0V", "a1"], 30 | ])("a:%s mid: %s b:%s", (a, expected, b) => { 31 | expect(generateKeyBetween(a, b, charSet)).toBe(expected); 32 | }); 33 | 34 | it("should throw if a >= b", () => { 35 | expect(() => generateKeyBetween("a0", "a0", charSet)).toThrow(); 36 | expect(() => generateKeyBetween("a1", "a0", charSet)).toThrow(); 37 | }); 38 | }); 39 | 40 | describe("generateJitteredKeyBetween", () => { 41 | const charSet = base62CharSet(); 42 | it.each([ 43 | // a, expected, b 44 | [null, "a06CO", null], 45 | [null, "a06CO", "a1"], 46 | [null, "Zz6CO", "a0"], 47 | [null, "b0S6CO", "b0T46n"], 48 | ["b0S", "b0T6CO", null], 49 | ["a0", "a46CO", "a8"], 50 | ["a0", "a0V6CO", "a1"], 51 | ])("a:%s mid: %s b:%s, should not mess up integer part", (a, expected, b) => { 52 | expect(generateJitteredKeyBetween(a, b, charSet)).toBe(expected); 53 | }); 54 | }); 55 | 56 | describe("generateNKeysBetween", () => { 57 | const charSet = base62CharSet(); 58 | it('should generate 3 keys between "a0" and "a1"', () => { 59 | const keys = generateNKeysBetween("a0", "a1", 3, charSet); 60 | expect(keys.length).toBe(3); 61 | expect(keys).toStrictEqual(["a0F", "a0V", "a0k"]); 62 | }); 63 | 64 | it('should generate 3 keys after "b01" ', () => { 65 | const keys = generateNKeysBetween("b01", null, 3, charSet); 66 | expect(keys.length).toBe(3); 67 | expect(keys).toStrictEqual(["b02", "b03", "b04"]); 68 | }); 69 | 70 | it('should generate 3 keys before "a0" ', () => { 71 | const keys = generateNKeysBetween(null, "a0", 3, charSet); 72 | expect(keys.length).toBe(3); 73 | expect(keys).toStrictEqual(["Zx", "Zy", "Zz"]); 74 | }); 75 | }); 76 | 77 | describe("generateNJitteredKeysBetween", () => { 78 | const charSet = base62CharSet(); 79 | it('should generate 3 keys between "a0" and "a1"', () => { 80 | const keys = generateNJitteredKeysBetween("a0", "a1", 3, charSet); 81 | expect(keys.length).toBe(3); 82 | expect(keys).toStrictEqual(["a0FeIa", "a0V6CO", "a0keIa"]); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/generateKeyBetween.ts: -------------------------------------------------------------------------------- 1 | import { IndexedCharacterSet, base62CharSet } from "./charSet"; 2 | import { 3 | decrementInteger, 4 | getIntegerPart, 5 | incrementInteger, 6 | startKey, 7 | validateOrderKey, 8 | } from "./integer"; 9 | import { 10 | jitterString, 11 | paddingNeededForJitter, 12 | padAndJitterString, 13 | } from "./jittering"; 14 | import { midPoint } from "./keyAsNumber"; 15 | 16 | /** 17 | * Generate a key between two other keys. 18 | * If either lower or upper is null, the key will be generated at the start or end of the list. 19 | */ 20 | export function generateKeyBetween( 21 | lower: string | null, 22 | upper: string | null, 23 | charSet: IndexedCharacterSet = base62CharSet() 24 | ): string { 25 | if (lower !== null) { 26 | validateOrderKey(lower, charSet); 27 | } 28 | if (upper !== null) { 29 | validateOrderKey(upper, charSet); 30 | } 31 | if (lower === null && upper === null) { 32 | return startKey(charSet); 33 | } 34 | if (lower === null) { 35 | const integer = getIntegerPart(upper!, charSet); 36 | return decrementInteger(integer, charSet); 37 | } 38 | if (upper === null) { 39 | const integer = getIntegerPart(lower, charSet); 40 | return incrementInteger(integer, charSet); 41 | } 42 | if (lower >= upper) { 43 | throw new Error(lower + " >= " + upper); 44 | } 45 | return midPoint(lower, upper, charSet); 46 | } 47 | 48 | /** 49 | * Generate any number of keys between two other keys. 50 | * If either lower or upper is null, the keys will be generated at the start or end of the list. 51 | */ 52 | export function generateNKeysBetween( 53 | a: string | null, 54 | b: string | null, 55 | n: number, 56 | charSet: IndexedCharacterSet = base62CharSet() 57 | ): string[] { 58 | return spreadGeneratorResults( 59 | a, 60 | b, 61 | n, 62 | charSet, 63 | generateKeyBetween, 64 | generateNKeysBetween 65 | ); 66 | } 67 | 68 | /** 69 | * Generate a key between two other keys with jitter. 70 | * If either lower or upper is null, the key will be generated at the start or end of the list. 71 | */ 72 | export function generateJitteredKeyBetween( 73 | lower: string | null, 74 | upper: string | null, 75 | charSet: IndexedCharacterSet = base62CharSet() 76 | ): string { 77 | const key = generateKeyBetween(lower, upper, charSet); 78 | const paddingNeeded = paddingNeededForJitter(key, upper, charSet); 79 | if (paddingNeeded) { 80 | return padAndJitterString(key, paddingNeeded, charSet); 81 | } 82 | return jitterString(key, charSet); 83 | } 84 | 85 | /** 86 | * Generate any number of keys between two other keys with jitter. 87 | * If either lower or upper is null, the keys will be generated at the start or end of the list. 88 | */ 89 | export function generateNJitteredKeysBetween( 90 | lower: string | null, 91 | upper: string | null, 92 | n: number, 93 | charSet: IndexedCharacterSet = base62CharSet() 94 | ): string[] { 95 | return spreadGeneratorResults( 96 | lower, 97 | upper, 98 | n, 99 | charSet, 100 | generateJitteredKeyBetween, 101 | generateNJitteredKeysBetween 102 | ); 103 | } 104 | 105 | function spreadGeneratorResults( 106 | lower: string | null, 107 | upper: string | null, 108 | n: number, 109 | charSet: IndexedCharacterSet, 110 | generateKey: GenerateKeyBetweenFunc, 111 | generateNKeys: GenerateNKeysBetweenFunc 112 | ) { 113 | if (n === 0) { 114 | return []; 115 | } 116 | if (n === 1) { 117 | return [generateKey(lower, upper, charSet)]; 118 | } 119 | if (upper == null) { 120 | let newUpper = generateKey(lower, upper, charSet); 121 | const result = [newUpper]; 122 | for (let i = 0; i < n - 1; i++) { 123 | newUpper = generateKey(newUpper, upper, charSet); 124 | result.push(newUpper); 125 | } 126 | return result; 127 | } 128 | if (lower == null) { 129 | let newLower = generateKey(lower, upper, charSet); 130 | const result = [newLower]; 131 | for (let i = 0; i < n - 1; i++) { 132 | newLower = generateKey(lower, newLower, charSet); 133 | result.push(newLower); 134 | } 135 | result.reverse(); 136 | return result; 137 | } 138 | const mid = Math.floor(n / 2); 139 | const midOrderKey = generateKey(lower, upper, charSet); 140 | return [ 141 | ...generateNKeys(lower, midOrderKey, mid, charSet), 142 | midOrderKey, 143 | ...generateNKeys(midOrderKey, upper, n - mid - 1, charSet), 144 | ]; 145 | } 146 | type GenerateKeyBetweenFunc = ( 147 | lower: string | null, 148 | upper: string | null, 149 | charSet?: IndexedCharacterSet 150 | ) => string; 151 | type GenerateNKeysBetweenFunc = ( 152 | lower: string | null, 153 | upper: string | null, 154 | n: number, 155 | charSet?: IndexedCharacterSet 156 | ) => string[]; 157 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | generateKeyBetween, 3 | generateNKeysBetween, 4 | generateJitteredKeyBetween, 5 | generateNJitteredKeysBetween, 6 | } from "./generateKeyBetween"; 7 | export { indexCharacterSet, base62CharSet } from "./charSet"; 8 | export { IndexGenerator } from "./IndexGenerator"; 9 | export type { GeneratorOptions } from "./IndexGenerator"; 10 | -------------------------------------------------------------------------------- /src/integer.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | startKey, 3 | integerHead, 4 | splitInteger, 5 | incrementInteger, 6 | incrementIntegerHead, 7 | decrementIntegerHead, 8 | decrementInteger, 9 | } from "./integer"; 10 | import { base62CharSet, indexCharacterSet } from "./charSet"; 11 | 12 | describe("startKey", () => { 13 | it("should return the first key for simple charset", () => { 14 | const charSet = indexCharacterSet({ chars: "01234567" }); 15 | expect(startKey(charSet)).toBe("40"); 16 | }); 17 | 18 | it("should return the first key for the base62FractionalCharset", () => { 19 | const charSet = base62CharSet(); 20 | expect(startKey(charSet)).toBe("a0"); 21 | }); 22 | }); 23 | 24 | describe("integerHead", () => { 25 | const charSet = base62CharSet(); 26 | it.each([ 27 | ["a0", "a"], 28 | ["a1", "a"], 29 | ["b01", "b"], 30 | ["Z1", "Z"], 31 | ["AZ00", "AZ"], 32 | ["AAZ000", "AAZ"], 33 | ["zb000", "zb"], 34 | ])("from %s to be %s", (interPart, expected) => { 35 | expect(integerHead(interPart, charSet)).toBe(expected); 36 | }); 37 | }); 38 | 39 | describe("splitInteger", () => { 40 | const charSet = base62CharSet(); 41 | it.each([ 42 | ["a0", ["a", "0"]], 43 | ["a1", ["a", "1"]], 44 | ["b01", ["b", "01"]], 45 | ["Z1", ["Z", "1"]], 46 | ["AZ00", ["AZ", "00"]], 47 | ["AAZ000", ["AAZ", "000"]], 48 | ["zb000", ["zb", "000"]], 49 | ])("from %s to be %s", (interPart, expected) => { 50 | expect(splitInteger(interPart, charSet)).toStrictEqual(expected); 51 | }); 52 | }); 53 | 54 | const incrementTestCases: [string, string][] = [ 55 | ["a", "b"], 56 | ["A", "B"], 57 | ["Z", "a"], 58 | ["y", "zA"], 59 | ["zy", "zzA"], 60 | ["AY", "AZ"], 61 | ["Az", "A"], 62 | ["AAz", "AA"], 63 | ]; 64 | 65 | describe("incrementIntegerHead", () => { 66 | const charSet = base62CharSet(); 67 | it.each(incrementTestCases)("from %s to be %s", (head, expected) => { 68 | expect(incrementIntegerHead(head, charSet)).toBe(expected); 69 | }); 70 | }); 71 | 72 | describe("decrementIntegerHead", () => { 73 | const charSet = base62CharSet(); 74 | // head and expected are reversed because we are decrementing using the same test cases 75 | it.each(incrementTestCases)("from %s to be %s", (expected, head) => { 76 | expect(decrementIntegerHead(head, charSet)).toBe(expected); 77 | }); 78 | }); 79 | 80 | const incrementTestCasesWithTail: [string, string][] = [ 81 | ["az", "b00"], 82 | ["bzz", "c000"], 83 | ["yzzzzzzzzzzzzzzzzzzzzzzzzz", "zA00000000000000000000000000"], 84 | ]; 85 | 86 | describe("incrementInteger", () => { 87 | const charSet = base62CharSet(); 88 | it.each(incrementTestCasesWithTail)( 89 | "from %s to be %s", 90 | (interPart, expected) => { 91 | expect(incrementInteger(interPart, charSet)).toBe(expected); 92 | } 93 | ); 94 | }); 95 | 96 | describe("decrementIntegerHead", () => { 97 | const charSet = base62CharSet(); 98 | it.each(incrementTestCasesWithTail)( 99 | "to be %s from %s", 100 | // head and expected are reversed because we are decrementing using the same test cases 101 | (expected, interPart) => { 102 | expect(decrementInteger(interPart, charSet)).toBe(expected); 103 | } 104 | ); 105 | }); 106 | -------------------------------------------------------------------------------- /src/integer.ts: -------------------------------------------------------------------------------- 1 | import { IndexedCharacterSet, IntegerLimits } from "./charSet"; 2 | import { integerLength } from "./integerLength"; 3 | import { decrementKey, incrementKey } from "./keyAsNumber"; 4 | 5 | export function startKey(charSet: IndexedCharacterSet) { 6 | return charSet.firstPositive + charSet.byCode[0]; 7 | } 8 | 9 | export function validInteger(integer: string, charSet: IndexedCharacterSet) { 10 | const length = integerLength(integer, charSet); 11 | return length === integer.length; 12 | } 13 | 14 | export function validateOrderKey( 15 | orderKey: string, 16 | charSet: IndexedCharacterSet 17 | ) { 18 | // TODO more checks 19 | getIntegerPart(orderKey, charSet); 20 | } 21 | 22 | export function getIntegerPart( 23 | orderKey: string, 24 | charSet: IndexedCharacterSet 25 | ): string { 26 | const head = integerHead(orderKey, charSet); 27 | const integerPartLength = integerLength(head, charSet); 28 | if (integerPartLength > orderKey.length) { 29 | throw new Error("invalid order key length: " + orderKey); 30 | } 31 | return orderKey.slice(0, integerPartLength); 32 | } 33 | 34 | function validateInteger(integer: string, charSet: IndexedCharacterSet) { 35 | if (!validInteger(integer, charSet)) { 36 | throw new Error("invalid integer length: " + integer); 37 | } 38 | } 39 | 40 | export function incrementInteger( 41 | integer: string, 42 | charSet: IndexedCharacterSet 43 | ) { 44 | validateInteger(integer, charSet); 45 | const [head, digs] = splitInteger(integer, charSet); 46 | const anyNonMaxedDigit = digs 47 | .split("") 48 | .some((d) => d !== charSet.byCode[charSet.length - 1]); 49 | 50 | // we have room to increment 51 | if (anyNonMaxedDigit) { 52 | const newDigits = incrementKey(digs, charSet); 53 | return head + newDigits; 54 | } 55 | const nextHead = incrementIntegerHead(head, charSet); 56 | return startOnNewHead(nextHead, "lower", charSet); 57 | } 58 | 59 | export function decrementInteger( 60 | integer: string, 61 | charSet: IndexedCharacterSet 62 | ) { 63 | validateInteger(integer, charSet); 64 | const [head, digs] = splitInteger(integer, charSet); 65 | const anyNonLimitDigit = digs.split("").some((d) => d !== charSet.byCode[0]); 66 | 67 | // we have room to decrement 68 | if (anyNonLimitDigit) { 69 | const newDigits = decrementKey(digs, charSet); 70 | return head + newDigits; 71 | } 72 | const nextHead = decrementIntegerHead(head, charSet); 73 | return startOnNewHead(nextHead, "upper", charSet); 74 | } 75 | 76 | export function integerHead(integer: string, charSet: IntegerLimits): string { 77 | let i = 0; 78 | if (integer[0] === charSet.mostPositive) { 79 | while (integer[i] === charSet.mostPositive) { 80 | i = i + 1; 81 | } 82 | } 83 | if (integer[0] === charSet.mostNegative) { 84 | while (integer[i] === charSet.mostNegative) { 85 | i = i + 1; 86 | } 87 | } 88 | return integer.slice(0, i + 1); 89 | } 90 | 91 | export function splitInteger( 92 | integer: string, 93 | charSet: IndexedCharacterSet 94 | ): [string, string] { 95 | const head = integerHead(integer, charSet); 96 | const tail = integer.slice(head.length); 97 | return [head, tail]; 98 | } 99 | 100 | export function incrementIntegerHead( 101 | head: string, 102 | charSet: IndexedCharacterSet 103 | ) { 104 | const inPositiveRange = head >= charSet.firstPositive; 105 | const nextHead = incrementKey(head, charSet); 106 | const headIsLimitMax = head[head.length - 1] === charSet.mostPositive; 107 | const nextHeadIsLimitMax = 108 | nextHead[nextHead.length - 1] === charSet.mostPositive; 109 | 110 | // we can not leave the head on the limit value, we have no way to know where the head ends 111 | if (inPositiveRange && nextHeadIsLimitMax) { 112 | return nextHead + charSet.mostNegative; 113 | } 114 | // we are already at the limit of this level, so we need to go up a level 115 | if (!inPositiveRange && headIsLimitMax) { 116 | return head.slice(0, head.length - 1); 117 | } 118 | return nextHead; 119 | } 120 | 121 | export function decrementIntegerHead( 122 | head: string, 123 | charSet: IndexedCharacterSet 124 | ) { 125 | const inPositiveRange = head >= charSet.firstPositive; 126 | const headIsLimitMin = head[head.length - 1] === charSet.mostNegative; 127 | if (inPositiveRange && headIsLimitMin) { 128 | const nextLevel = head.slice(0, head.length - 1); 129 | // we can not leave the head on the limit value, we have no way to know where the head ends 130 | // so we take one extra step down 131 | return decrementKey(nextLevel, charSet); 132 | } 133 | 134 | if (!inPositiveRange && headIsLimitMin) { 135 | return head + charSet.mostPositive; 136 | } 137 | 138 | return decrementKey(head, charSet); 139 | } 140 | 141 | function startOnNewHead( 142 | head: string, 143 | limit: "upper" | "lower", 144 | charSet: IndexedCharacterSet 145 | ) { 146 | const newLength = integerLength(head, charSet); 147 | const fillChar = 148 | limit === "upper" ? charSet.byCode[charSet.length - 1] : charSet.byCode[0]; 149 | return head + fillChar.repeat(newLength - head.length); 150 | } 151 | -------------------------------------------------------------------------------- /src/integerLength.spec.ts: -------------------------------------------------------------------------------- 1 | import { base62CharSet, indexCharacterSet } from "./charSet"; 2 | import { distanceBetween, integerLength } from "./integerLength"; 3 | 4 | describe("distanceNeutral", () => { 5 | const charSet = base62CharSet(); 6 | 7 | it.each([ 8 | ["a", 0], 9 | ["Z", 1], 10 | ["b", 1], 11 | ["A", 26], 12 | ["z", 25], 13 | ])("from %s to be %s", (interPart, expectedLength) => { 14 | expect(distanceBetween(interPart, charSet.firstPositive, charSet)).toBe( 15 | expectedLength 16 | ); 17 | }); 18 | }); 19 | 20 | describe("integerLength", () => { 21 | const charSet = base62CharSet(); 22 | it.each([ 23 | // Positive 24 | ["a", 2], 25 | ["b", 3], 26 | ["y", 26], 27 | ["zA", 28], 28 | ["zB", 29], 29 | ["zC", 30], 30 | ["zx", 77], 31 | ["zy", 78], 32 | ["zzA", 80], 33 | ["zzy", 130], 34 | ["zzzA", 132], 35 | // Negative 36 | ["Z", 2], 37 | ["B", 26], 38 | ["Az", 28], 39 | ["Ay", 29], 40 | ["AB", 78], 41 | ["AAz", 80], 42 | ["AAB", 130], 43 | ["AAAz", 132], 44 | ])("from %s to be %s", (interPart, expectedLength) => { 45 | expect(integerLength(interPart, charSet)).toBe(expectedLength); 46 | }); 47 | 48 | it("should throw for invalid head", () => { 49 | expect(() => integerLength("0", charSet)).toThrow(); 50 | }); 51 | 52 | it("should throw for invalid head on second level", () => { 53 | expect(() => integerLength("A0", charSet)).toThrow(); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/integerLength.ts: -------------------------------------------------------------------------------- 1 | import { IndexedCharacterSet } from "./charSet"; 2 | 3 | export function distanceBetween( 4 | a: string, 5 | b: string, 6 | charSet: IndexedCharacterSet 7 | ) { 8 | const indexA = charSet.byChar[a]; 9 | const indexB = charSet.byChar[b]; 10 | return Math.abs(indexA - indexB); 11 | } 12 | 13 | export function integerLength( 14 | head: string, 15 | charSet: IndexedCharacterSet 16 | ): number { 17 | const firstChar = head[0]; 18 | if (firstChar > charSet.mostPositive || firstChar < charSet.mostNegative) { 19 | throw new Error("invalid firstChar on key"); 20 | } 21 | if (firstChar === charSet.mostPositive) { 22 | const firstLevel = 23 | distanceBetween(firstChar, charSet.firstPositive, charSet) + 1; 24 | return ( 25 | firstLevel + 26 | integerLengthFromSecondLevel(head.slice(1), "positive", charSet) 27 | ); 28 | } 29 | if (firstChar === charSet.mostNegative) { 30 | const firstLevel = 31 | distanceBetween(firstChar, charSet.firstNegative, charSet) + 1; 32 | return ( 33 | firstLevel + 34 | integerLengthFromSecondLevel(head.slice(1), "negative", charSet) 35 | ); 36 | } 37 | const isPositiveRange = firstChar >= charSet.firstPositive; 38 | if (isPositiveRange) { 39 | return distanceBetween(firstChar, charSet.firstPositive, charSet) + 2; 40 | } else { 41 | return distanceBetween(firstChar, charSet.firstNegative, charSet) + 2; 42 | } 43 | } 44 | 45 | function integerLengthFromSecondLevel( 46 | key: string, 47 | direction: "positive" | "negative", 48 | charSet: IndexedCharacterSet 49 | ): number { 50 | const firstChar = key[0]; 51 | if (firstChar > charSet.mostPositive || firstChar < charSet.mostNegative) { 52 | throw new Error("invalid firstChar on key"); 53 | } 54 | if (firstChar === charSet.mostPositive && direction === "positive") { 55 | const totalPositiveRoom = 56 | distanceBetween(firstChar, charSet.mostNegative, charSet) + 1; 57 | return ( 58 | totalPositiveRoom + 59 | integerLengthFromSecondLevel(key.slice(1), direction, charSet) 60 | ); 61 | } 62 | if (firstChar === charSet.mostNegative && direction === "negative") { 63 | const totalNegativeRoom = 64 | distanceBetween(firstChar, charSet.mostPositive, charSet) + 1; 65 | return ( 66 | totalNegativeRoom + 67 | integerLengthFromSecondLevel(key.slice(1), direction, charSet) 68 | ); 69 | } 70 | if (direction === "positive") { 71 | return distanceBetween(firstChar, charSet.mostNegative, charSet) + 2; 72 | } else { 73 | return distanceBetween(firstChar, charSet.mostPositive, charSet) + 2; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/jittering.spec.ts: -------------------------------------------------------------------------------- 1 | import { base62CharSet } from "./charSet"; 2 | import { 3 | jitterString, 4 | paddingNeededForJitter, 5 | padAndJitterString, 6 | paddingNeededForDistance, 7 | } from "./jittering"; 8 | 9 | beforeEach(() => { 10 | jest.spyOn(global.Math, "random").mockReturnValue(0.5); 11 | }); 12 | 13 | afterEach(() => { 14 | jest.spyOn(global.Math, "random").mockRestore(); 15 | }); 16 | 17 | describe("jitterString", () => { 18 | it("should return a padded string", () => { 19 | expect(jitterString("a0000", base62CharSet())).toBe("a06CO"); 20 | }); 21 | }); 22 | 23 | describe("padAndJitterString", () => { 24 | it("should return a padded string", () => { 25 | expect(padAndJitterString("a0", 3, base62CharSet())).toBe("a06CO"); 26 | }); 27 | }); 28 | 29 | describe("paddingNeededForJitter", () => { 30 | it("should return 3 for a key that very close to the next integer", () => { 31 | expect(paddingNeededForJitter("a0", null, base62CharSet())).toBe(3); 32 | }); 33 | it("should return 3 for a key that is very close to the upper between", () => { 34 | expect(paddingNeededForJitter("a000001", "a000002", base62CharSet())).toBe( 35 | 3 36 | ); 37 | }); 38 | 39 | it("should return 0 for a key that has room until the next integer", () => { 40 | expect(paddingNeededForJitter("a000001", null, base62CharSet())).toBe(0); 41 | }); 42 | 43 | it("should return 2 for a key that just misses a little room", () => { 44 | expect(paddingNeededForJitter("a01001", "a01C00", base62CharSet())).toBe(2); 45 | }); 46 | 47 | // it("should return 0 for a key that has room until the next integer and the upper", () => { 48 | // expect(paddingNeededForJitter("a000001", "a001000", base62CharSet())).toBe( 49 | // 0 50 | // ); 51 | // }); 52 | }); 53 | 54 | describe("paddingNeededForDistance", () => { 55 | it.each([ 56 | [1, 3], 57 | [100, 3], 58 | [45000, 2], 59 | [100000, 0], 60 | ])("from %s to be %s", (distance, needed) => { 61 | expect(paddingNeededForDistance(distance, base62CharSet())).toBe(needed); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/jittering.ts: -------------------------------------------------------------------------------- 1 | import { IndexedCharacterSet } from "./charSet"; 2 | import { getIntegerPart, incrementInteger } from "./integer"; 3 | import { 4 | addCharSetKeys, 5 | encodeToCharSet, 6 | lexicalDistance, 7 | } from "./keyAsNumber"; 8 | 9 | export function jitterString( 10 | orderKey: string, 11 | charSet: IndexedCharacterSet 12 | ): string { 13 | const shift = encodeToCharSet( 14 | Math.floor(Math.random() * charSet.jitterRange), 15 | charSet 16 | ); 17 | return addCharSetKeys(orderKey, shift, charSet); 18 | } 19 | 20 | export function padAndJitterString( 21 | orderKey: string, 22 | numberOfChars: number, 23 | charSet: IndexedCharacterSet 24 | ): string { 25 | const paddedKey = orderKey.padEnd( 26 | orderKey.length + numberOfChars, 27 | charSet.first 28 | ); 29 | return jitterString(paddedKey, charSet); 30 | } 31 | 32 | export function paddingNeededForJitter( 33 | orderKey: string, 34 | b: string | null, 35 | charSet: IndexedCharacterSet 36 | ): number { 37 | const integer = getIntegerPart(orderKey, charSet); 38 | const nextInteger = incrementInteger(integer, charSet); 39 | let needed = 0; 40 | if (b !== null) { 41 | const distanceToB = lexicalDistance(orderKey, b, charSet); 42 | if (distanceToB < charSet.jitterRange + 1) { 43 | needed = Math.max(needed, paddingNeededForDistance(distanceToB, charSet)); 44 | } 45 | } 46 | const distanceToNextInteger = lexicalDistance(orderKey, nextInteger, charSet); 47 | if (distanceToNextInteger < charSet.jitterRange + 1) { 48 | needed = Math.max( 49 | needed, 50 | paddingNeededForDistance(distanceToNextInteger, charSet) 51 | ); 52 | } 53 | 54 | return needed; 55 | } 56 | 57 | export function paddingNeededForDistance( 58 | distance: number, 59 | charSet: IndexedCharacterSet 60 | ): number { 61 | const gap = charSet.jitterRange - distance; 62 | const firstBigger = Object.entries(charSet.paddingDict).find( 63 | ([_key, value]) => { 64 | return value > gap; 65 | } 66 | ); 67 | 68 | return firstBigger ? parseInt(firstBigger[0]) : 0; 69 | } 70 | -------------------------------------------------------------------------------- /src/keyAsNumber.spec.ts: -------------------------------------------------------------------------------- 1 | import { base62CharSet, indexCharacterSet } from "./charSet"; 2 | import { 3 | addCharSetKeys, 4 | decrementKey, 5 | lexicalDistance, 6 | midPoint, 7 | subtractCharSetKeys, 8 | } from "./keyAsNumber"; 9 | 10 | const base10Charset = indexCharacterSet({ 11 | chars: "0123456789", 12 | }); 13 | 14 | describe("midPoint", () => { 15 | const charSet = base62CharSet(); 16 | it.each([ 17 | ["a0", "a4", "a8"], 18 | ["a0", "a3", "a7"], 19 | ["a0", "b0", "c0"], 20 | ["a00001", "b00001", "c00001"], 21 | ["a0", "a0V", "a1"], 22 | ])("base62: a:%s mid: %s b:%s", (lower, mid, upper) => { 23 | expect(midPoint(lower, upper, charSet)).toBe(mid); 24 | }); 25 | 26 | it.each([ 27 | ["0", "1", "2"], 28 | ["10", "15", "20"], 29 | ["0", "05", "10"], 30 | ])("base 10: a:%s mid: %s b:%s", (lower, mid, upper) => { 31 | expect(midPoint(lower, upper, base10Charset)).toBe(mid); 32 | }); 33 | }); 34 | 35 | const base62Cases: [a: string, b: string, added: string][] = [ 36 | ["a0", "1", "a1"], 37 | ["1", "a0", "a1"], 38 | ["Zz", "1", "a0"], 39 | ["0a0", "1", "0a1"], 40 | ]; 41 | 42 | const base10Cases: [a: string, b: string, added: string][] = [ 43 | ["1", "5", "6"], 44 | ["5", "7", "12"], 45 | ]; 46 | 47 | describe("addCharSetKey", () => { 48 | it.each(base62Cases)("base62 a:%s b: %s added:%s", (a, b, added) => { 49 | expect(addCharSetKeys(a, b, base62CharSet())).toBe(added); 50 | }); 51 | it.each(base10Cases)("base10 a: %s b: %s added: %s", (a, b, added) => { 52 | expect(addCharSetKeys(a, b, base10Charset)).toBe(added); 53 | }); 54 | }); 55 | 56 | describe("subtractCharSetKeys", () => { 57 | it.each(base62Cases)("base62 a:%s b: %s added:%s", (a, b, added) => { 58 | expect(subtractCharSetKeys(added, a, base62CharSet())).toBe(b); 59 | }); 60 | it.each(base10Cases)("base10 a:%s b: %s added:%s", (a, b, added) => { 61 | expect(subtractCharSetKeys(added, a, base10Charset)).toBe(b); 62 | }); 63 | 64 | it("should throw if a < b", () => { 65 | expect(() => subtractCharSetKeys("1", "10", base10Charset)).toThrow(); 66 | }); 67 | }); 68 | 69 | describe("lexicalDistance", () => { 70 | const charSet = base62CharSet(); 71 | it.each([ 72 | ["a0", "a4", 4], 73 | ["a", "a4", 4], 74 | ["a1", "b1", 62], 75 | ["b1", "a1", 62], 76 | ["a1", "a2", 1], 77 | ["a10", "a20", 62], 78 | ["0a10", "0a20", 62], 79 | ])("a: %s b: %s distance: %s", (a, b, distance) => { 80 | expect(lexicalDistance(a, b, charSet)).toBe(distance); 81 | }); 82 | }); 83 | 84 | describe("decrementKey", () => { 85 | const charSet = base62CharSet(); 86 | it.each([ 87 | ["a0", "Zz"], 88 | ["Zz", "Zy"], 89 | ["a1", "a0"], 90 | ["b0T", "b0S"], 91 | ])("a: %s b: %s distance: %s", (key, result) => { 92 | expect(decrementKey(key, charSet)).toBe(result); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/keyAsNumber.ts: -------------------------------------------------------------------------------- 1 | import { IndexedCharacterSet } from "./charSet"; 2 | import { makeSameLength } from "./padToSameLength"; 3 | 4 | /** 5 | * Returns the midpoint between two string keys based on a given charSet. 6 | */ 7 | export function midPoint( 8 | lower: string, 9 | upper: string, 10 | charSet: IndexedCharacterSet 11 | ): string { 12 | let [paddedLower, paddedUpper] = makeSameLength( 13 | lower, 14 | upper, 15 | "end", 16 | charSet.first 17 | ); 18 | let distance = lexicalDistance(paddedLower, paddedUpper, charSet); 19 | if (distance === 1) { 20 | // if the numbers are consecutive we need more padding 21 | paddedLower = paddedLower.padEnd(paddedLower.length + 1, charSet.first); 22 | // the new distance will always be the length of the charSet 23 | distance = charSet.length; 24 | } 25 | const mid = encodeToCharSet(Math.floor(distance / 2), charSet); 26 | return addCharSetKeys(paddedLower, mid, charSet); 27 | } 28 | 29 | /** 30 | the distance between two keys when sorting them as strings 31 | this is not the same as the distance between the numbers they encode 32 | */ 33 | export function lexicalDistance( 34 | a: string, 35 | b: string, 36 | charSet: IndexedCharacterSet 37 | ) { 38 | const [lower, upper] = makeSameLength(a, b, "end", charSet.first).sort(); 39 | const distance = subtractCharSetKeys(upper, lower, charSet); 40 | return decodeCharSetToNumber(distance, charSet); 41 | } 42 | 43 | export function addCharSetKeys( 44 | a: string, 45 | b: string, 46 | charSet: IndexedCharacterSet 47 | ): string { 48 | const base = charSet.length; 49 | const [paddedA, paddedB] = makeSameLength(a, b, "start", charSet.first); 50 | 51 | const result: string[] = []; 52 | let carry = 0; 53 | 54 | // Iterate over the digits from right to left 55 | for (let i = paddedA.length - 1; i >= 0; i--) { 56 | const digitA = charSet.byChar[paddedA[i]]; 57 | const digitB = charSet.byChar[paddedB[i]]; 58 | const sum = digitA + digitB + carry; 59 | carry = Math.floor(sum / base); 60 | const remainder = sum % base; 61 | 62 | result.unshift(charSet.byCode[remainder]); 63 | } 64 | 65 | // If there's a carry left, add it to the result 66 | if (carry > 0) { 67 | result.unshift(charSet.byCode[carry]); 68 | } 69 | 70 | return result.join(""); 71 | } 72 | 73 | export function subtractCharSetKeys( 74 | a: string, 75 | b: string, 76 | charSet: IndexedCharacterSet, 77 | stripLeadingZeros = true 78 | ): string { 79 | const base = charSet.length; 80 | const [paddedA, paddedB] = makeSameLength(a, b, "start", charSet.first); 81 | 82 | const result: string[] = []; 83 | let borrow = 0; 84 | 85 | // Iterate over the digits from right to left 86 | for (let i = paddedA.length - 1; i >= 0; i--) { 87 | let digitA = charSet.byChar[paddedA[i]]; 88 | const digitB = charSet.byChar[paddedB[i]] + borrow; 89 | 90 | // Handle borrowing 91 | if (digitA < digitB) { 92 | borrow = 1; 93 | digitA += base; 94 | } else { 95 | borrow = 0; 96 | } 97 | 98 | const difference = digitA - digitB; 99 | result.unshift(charSet.byCode[difference]); 100 | } 101 | 102 | // If there's a borrow left, we have a negative result, which is not supported 103 | if (borrow > 0) { 104 | throw new Error( 105 | "Subtraction result is negative. Ensure a is greater than or equal to b." 106 | ); 107 | } 108 | 109 | // Remove leading zeros 110 | while ( 111 | stripLeadingZeros && 112 | result.length > 1 && 113 | result[0] === charSet.first 114 | ) { 115 | result.shift(); 116 | } 117 | 118 | return result.join(""); 119 | } 120 | 121 | export function incrementKey(key: string, charSet: IndexedCharacterSet) { 122 | return addCharSetKeys(key, charSet.byCode[1], charSet); 123 | } 124 | 125 | export function decrementKey(key: string, charSet: IndexedCharacterSet) { 126 | // we should not strip leading zeros here, this will break the sorting if the key already has leading zeros 127 | return subtractCharSetKeys(key, charSet.byCode[1], charSet, false); 128 | } 129 | 130 | export function encodeToCharSet(int: number, charSet: IndexedCharacterSet) { 131 | if (int === 0) { 132 | return charSet.byCode[0]; 133 | } 134 | let res = ""; 135 | const max = charSet.length; 136 | while (int > 0) { 137 | res = charSet.byCode[int % max] + res; 138 | int = Math.floor(int / max); 139 | } 140 | return res; 141 | } 142 | 143 | // be careful not to use this for full order keys, as javascript will loose precision with numbers from 2^53 144 | // using base62 that can already happen with 9 characters 145 | export function decodeCharSetToNumber( 146 | key: string, 147 | charSet: IndexedCharacterSet 148 | ) { 149 | let res = 0; 150 | const length = key.length; 151 | const max = charSet.length; 152 | for (let i = 0; i < length; i++) { 153 | res += charSet.byChar[key[i]] * Math.pow(max, length - i - 1); 154 | } 155 | return res; 156 | } 157 | -------------------------------------------------------------------------------- /src/padToSameLength.ts: -------------------------------------------------------------------------------- 1 | export function makeSameLength( 2 | a: string, 3 | b: string, 4 | pad: "start" | "end", 5 | fillChar: string, 6 | forceLength?: number 7 | ) { 8 | const max = forceLength ?? Math.max(a.length, b.length); 9 | if (pad === "start") { 10 | return [a.padStart(max, fillChar), b.padStart(max, fillChar)]; 11 | } 12 | return [a.padEnd(max, fillChar), b.padEnd(max, fillChar)]; 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationMap": true, 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true 8 | }, 9 | "include": ["src"], 10 | "exclude": ["node_modules", "**/__tests__/*", "src/**/*.spec.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "lib": ["ES6", "DOM"], 5 | "target": "ES6", 6 | "module": "CommonJS", 7 | "moduleResolution": "Node", 8 | "outDir": "./lib/cjs" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "lib": ["ES2022", "DOM"], 5 | "target": "ES2022", 6 | "module": "NodeNext", 7 | "moduleResolution": "NodeNext", 8 | "outDir": "./lib/esm" 9 | } 10 | } 11 | --------------------------------------------------------------------------------