Skip to content

Commit 0c2c22b

Browse files
Rotate CLI partners by sponsorship tier (#486)
1 parent 12c31df commit 0c2c22b

16 files changed

Lines changed: 194 additions & 16 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@tanstack/cli': patch
3+
'@tanstack/create': patch
4+
---
5+
6+
Order partner integrations by sponsorship tier and rotate integrations within each tier for every CLI run.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { randomUUID } from 'node:crypto'
2+
3+
import type { AddOn } from '@tanstack/create'
4+
5+
type PartnerTier = NonNullable<AddOn['partner']>['tier']
6+
7+
const partnerTierOrder = {
8+
gold: 0,
9+
silver: 1,
10+
bronze: 2,
11+
} satisfies Record<PartnerTier, number>
12+
13+
const cliSessionSeed = randomUUID()
14+
15+
function hashString(value: string) {
16+
let hash = 2166136261
17+
18+
for (let index = 0; index < value.length; index++) {
19+
hash ^= value.charCodeAt(index)
20+
hash = Math.imul(hash, 16777619)
21+
}
22+
23+
return hash >>> 0
24+
}
25+
26+
function compareIdentity(left: AddOn, right: AddOn) {
27+
const nameComparison = left.name.localeCompare(right.name)
28+
return nameComparison || left.id.localeCompare(right.id)
29+
}
30+
31+
function compareSeededPartnerOrder(left: AddOn, right: AddOn, seed: string) {
32+
const leftWeight = left.partner!.placementWeight ?? 1
33+
const rightWeight = right.partner!.placementWeight ?? 1
34+
const leftRandom =
35+
(hashString(`${seed}:${left.partner!.id}`) + 1) / 4294967297
36+
const rightRandom =
37+
(hashString(`${seed}:${right.partner!.id}`) + 1) / 4294967297
38+
const seededComparison =
39+
-Math.log(leftRandom) / leftWeight + Math.log(rightRandom) / rightWeight
40+
41+
return seededComparison || compareIdentity(left, right)
42+
}
43+
44+
export function orderAddOnsForPartnerPlacement(
45+
addOns: Array<AddOn>,
46+
surface: string,
47+
sessionSeed = cliSessionSeed,
48+
) {
49+
const rotationSeed = `${surface}:${sessionSeed}`
50+
const partners = addOns
51+
.filter((addOn) => addOn.partner)
52+
.sort((left, right) => {
53+
const tierComparison =
54+
partnerTierOrder[left.partner!.tier] -
55+
partnerTierOrder[right.partner!.tier]
56+
57+
if (tierComparison !== 0) {
58+
return tierComparison
59+
}
60+
61+
if (surface === 'deployment' && left.partner!.id === 'cloudflare') {
62+
return -1
63+
}
64+
65+
if (surface === 'deployment' && right.partner!.id === 'cloudflare') {
66+
return 1
67+
}
68+
69+
return compareSeededPartnerOrder(left, right, rotationSeed)
70+
})
71+
const nonPartners = addOns.filter((addOn) => !addOn.partner)
72+
73+
return [...partners, ...nonPartners]
74+
}

packages/cli/src/ui-prompts.ts

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
isCurrentDirectoryProjectNameInput,
2020
validateProjectName,
2121
} from './utils.js'
22+
import { orderAddOnsForPartnerPlacement } from './partner-placement.js'
2223
import type { AddOn, PackageManager } from '@tanstack/create'
2324

2425
import type { Framework } from '@tanstack/create/dist/types/types.js'
@@ -154,8 +155,9 @@ export async function selectAddOns(
154155
}
155156

156157
if (allowMultiple) {
157-
const selectableAddOns = addOns.filter(
158-
(addOn) => !forcedAddOns.includes(addOn.id),
158+
const selectableAddOns = orderAddOnsForPartnerPlacement(
159+
addOns.filter((addOn) => !forcedAddOns.includes(addOn.id)),
160+
'add-ons',
159161
)
160162

161163
if (selectableAddOns.length === 0) {
@@ -381,19 +383,21 @@ export async function selectDeployment(
381383
): Promise<string | undefined> {
382384
const deployments = new Set<AddOn>()
383385
let initialValue: string | undefined = undefined
384-
for (const addOn of framework
385-
.getAddOns()
386-
.sort((a, b) => a.name.localeCompare(b.name))) {
387-
if (addOn.type === 'deployment') {
388-
deployments.add(addOn)
389-
if (deployment && addOn.id === deployment) {
390-
return deployment
391-
}
392-
if (forcedDeployment && addOn.id === forcedDeployment) {
393-
initialValue = addOn.id
394-
} else if (!initialValue && addOn.default) {
395-
initialValue = addOn.id
396-
}
386+
for (const addOn of orderAddOnsForPartnerPlacement(
387+
framework
388+
.getAddOns()
389+
.filter((addOn) => addOn.type === 'deployment')
390+
.sort((a, b) => a.name.localeCompare(b.name)),
391+
'deployment',
392+
)) {
393+
deployments.add(addOn)
394+
if (deployment && addOn.id === deployment) {
395+
return deployment
396+
}
397+
if (forcedDeployment && addOn.id === forcedDeployment) {
398+
initialValue = addOn.id
399+
} else if (!initialValue && addOn.default) {
400+
initialValue = addOn.id
397401
}
398402
}
399403

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { orderAddOnsForPartnerPlacement } from '../src/partner-placement'
4+
5+
import type { AddOn } from '@tanstack/create'
6+
7+
function addOn(
8+
id: string,
9+
partner?: {
10+
id: string
11+
tier: 'gold' | 'silver' | 'bronze'
12+
placementWeight?: number
13+
},
14+
) {
15+
return { id, name: id, partner } as AddOn
16+
}
17+
18+
describe('partner placement', () => {
19+
it('orders partners by tier ahead of non-partners', () => {
20+
const ordered = orderAddOnsForPartnerPlacement(
21+
[
22+
addOn('other'),
23+
addOn('bronze', { id: 'bronze', tier: 'bronze' }),
24+
addOn('gold', { id: 'gold', tier: 'gold' }),
25+
addOn('silver', { id: 'silver', tier: 'silver' }),
26+
],
27+
'add-ons',
28+
'test',
29+
)
30+
31+
expect(ordered.map((item) => item.id)).toEqual([
32+
'gold',
33+
'silver',
34+
'bronze',
35+
'other',
36+
])
37+
})
38+
39+
it('rotates partners within a tier from the CLI session seed', () => {
40+
const partners = [
41+
addOn('clerk', { id: 'clerk', tier: 'silver' }),
42+
addOn('workos', { id: 'workos', tier: 'silver' }),
43+
]
44+
45+
expect(
46+
orderAddOnsForPartnerPlacement(partners, 'add-ons', '0').map(
47+
(item) => item.id,
48+
),
49+
).toEqual(['clerk', 'workos'])
50+
expect(
51+
orderAddOnsForPartnerPlacement(partners, 'add-ons', '4').map(
52+
(item) => item.id,
53+
),
54+
).toEqual(['workos', 'clerk'])
55+
})
56+
57+
it('reserves Cloudflare as the first deployment partner', () => {
58+
const ordered = orderAddOnsForPartnerPlacement(
59+
[
60+
addOn('netlify', { id: 'netlify', tier: 'gold' }),
61+
addOn('cloudflare', { id: 'cloudflare', tier: 'gold' }),
62+
addOn('railway', { id: 'railway', tier: 'gold' }),
63+
],
64+
'deployment',
65+
'test',
66+
)
67+
68+
expect(ordered[0]?.id).toBe('cloudflare')
69+
expect(
70+
ordered
71+
.slice(1)
72+
.map((item) => item.id)
73+
.sort(),
74+
).toEqual(['netlify', 'railway'])
75+
})
76+
})

packages/create/src/frameworks/react/add-ons/clerk/info.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"exclusive": ["auth"],
99
"color": "#6C47FF",
1010
"priority": 150,
11+
"partner": { "id": "clerk", "tier": "silver" },
1112
"link": "https://clerk.com",
1213
"routes": [
1314
{

packages/create/src/frameworks/react/add-ons/prisma/info.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
"exclusive": ["orm"],
88
"color": "#2D3748",
99
"priority": 120,
10+
"partner": { "id": "prisma", "tier": "bronze" },
1011
"link": "https://www.prisma.io/",
1112
"modes": ["file-router"],
12-
13+
1314
"postInitSpecialSteps": ["post-init-script"],
1415
"options": {
1516
"database": {

packages/create/src/frameworks/react/add-ons/sentry/info.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"category": "monitoring",
99
"color": "#362D59",
1010
"priority": 130,
11+
"partner": { "id": "sentry", "tier": "bronze" },
1112
"routes": [
1213
{
1314
"url": "/demo/sentry/testing",

packages/create/src/frameworks/react/add-ons/workos/info.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"exclusive": ["auth"],
99
"color": "#6363F1",
1010
"priority": 160,
11+
"partner": { "id": "workos", "tier": "silver" },
1112
"link": "https://workos.com",
1213
"routes": [
1314
{

packages/create/src/frameworks/react/hosts/cloudflare/info.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"exclusive": ["deploy"],
1010
"color": "#F38020",
1111
"priority": 200,
12+
"partner": { "id": "cloudflare", "tier": "gold" },
1213
"integrations": [
1314
{
1415
"type": "vite-plugin",

packages/create/src/frameworks/react/hosts/netlify/info.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"exclusive": ["deploy"],
1010
"color": "#00C7B7",
1111
"priority": 180,
12+
"partner": { "id": "netlify", "tier": "gold" },
1213
"integrations": [
1314
{
1415
"type": "vite-plugin",

0 commit comments

Comments
 (0)