├── .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 | You need to enable JavaScript to run this app.
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 |
14 | Home Fractional indexing jittered examples
15 |
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 |
9 | {children}
10 |
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 |
84 |
85 | {item.order} {groups[groupId]}
86 |
87 | handleBefore(item.order)}>
88 | 1 Before
89 |
90 | handleNBefore(item.order, 3)}>
91 | 3 Before
92 |
93 | handleAfter(item.order)}>1 After
94 | handleNAfter(item.order, 3)}>
95 | 3 After
96 |
97 |
98 |
99 |
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 |
141 |
142 |
143 | {item.order} {item.user}
144 |
145 |
146 | handleNBefore(item.order, 1)}>
147 | 1 Before
148 |
149 | handleNBefore(item.order, 3)}>
150 | 3 Before
151 |
152 | handleNAfter(item.order, 1)}>
153 | After
154 |
155 | handleNAfter(item.order, 3)}>
156 | 3 After
157 |
158 |
159 |
160 |
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 |
140 |
141 |
142 | {item.order} {item.user}
143 |
144 |
145 | handleNBefore(item.order, 1)}>
146 | 1 Before
147 |
148 | handleNBefore(item.order, 3)}>
149 | 3 Before
150 |
151 | handleNAfter(item.order, 1)}>
152 | After
153 |
154 | handleNAfter(item.order, 3)}>
155 | 3 After
156 |
157 |
158 |
159 |
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 |
Prepend
74 |
Append
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 | handleNBefore(item.order, 1)}>
110 | 1 Before
111 |
112 | handleNBefore(item.order, 3)}>
113 | 3 Before
114 |
115 | handleNAfter(item.order, 1)}>After
116 | handleNAfter(item.order, 3)}>
117 | 3 After
118 |
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 | Prepend
62 | Append
63 |
64 | {list.map((item) => (
65 |
66 |
67 | {item.order}
68 |
69 | handleBefore(item.order)}>
70 | 1 Before
71 |
72 | handleNBefore(item.order, 3)}>
73 | 3 Before
74 |
75 | handleAfter(item.order)}>1 After
76 | handleNAfter(item.order, 3)}>
77 | 3 After
78 |
79 |
80 |
81 |
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 | Prepend
70 | Append
71 |
72 | {list.map((item) => (
73 |
74 |
75 | {item.order}
76 |
77 | handleBefore(item.order)}>
78 | 1 Before
79 |
80 | handleNBefore(item.order, 3)}>
81 | 3 Before
82 |
83 | handleAfter(item.order)}>1 After
84 | handleNAfter(item.order, 3)}>
85 | 3 After
86 |
87 |
88 |
89 |
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 |
--------------------------------------------------------------------------------