Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplify releaseTrackSet using release_tracks meta #10248

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
37 changes: 37 additions & 0 deletions app/adapters/crate.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,43 @@ const BULK_REQUEST_GROUP_SIZE = 10;
export default class CrateAdapter extends ApplicationAdapter {
coalesceFindRequests = true;

async findHasMany(store, snapshot, url, relationship) {
if (relationship.key === 'versions') {
let { adapterOptions } = snapshot;
let data;
if (adapterOptions?.withReleaseTracks === true) {
data = { include: 'release_tracks' };
}
return this.ajax(url, 'GET', { data }).then(resp => {
let crate = store.peekRecord('crate', snapshot.id);
if (resp.meta) {
let payload = {
crate: {
id: snapshot.id,
versions_meta: {
...crate.versions_meta,
...resp.meta,
},
},
};
store.pushPayload(payload);
}
return resp;
});
}

return super.findHasMany(store, snapshot, url, relationship);
}

findRecord(store, type, id, snapshot) {
let { include } = snapshot;
// This ensures `crate.versions` are always fetched from another request.
if (include === undefined) {
snapshot.include = 'keywords,categories,badges,downloads';
}
return super.findRecord(store, type, id, snapshot);
}

groupRecordsForFindMany(store, snapshots) {
let result = [];
for (let i = 0; i < snapshots.length; i += BULK_REQUEST_GROUP_SIZE) {
Expand Down
50 changes: 39 additions & 11 deletions app/models/crate.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ export default class Crate extends Model {
@attr max_version;
@attr max_stable_version;
@attr newest_version;
/**
* @typedef {Object} VersionsMeta
* @property {number} total
* @property {string | null} next_page
* @property {Object.<string, ReleaseTrackDetails>} release_tracks
*
* @typedef {Object} ReleaseTrackDetails
* @property {string} highest
**/
/**
* This isn't an attribute in the crate response.
* It's actually the `meta` attribute that belongs to `versions`
* and needs to be assigned to `crate` manually.
* @type {VersionsMeta | null}
**/
@attr versions_meta;

@attr description;
@attr homepage;
Expand Down Expand Up @@ -67,16 +83,17 @@ export default class Crate extends Model {
return Object.fromEntries(versions.slice().map(v => [v.id, v]));
}

/**
* @type {Set<string>}
**/
@cached get releaseTrackSet() {
let map = new Map();
let { versionsObj: versions, versionIdsBySemver } = this;
for (let id of versionIdsBySemver) {
let { releaseTrack, isPrerelease, yanked } = versions[id];
if (releaseTrack && !isPrerelease && !yanked && !map.has(releaseTrack)) {
map.set(releaseTrack, id);
}
}
return new Set(map.values());
let { release_tracks } = this.versions_meta ?? {};
assert(
'`loadVersionsTask.perform({ withReleaseTracks: true })` must be called before calling `releaseTrackSet`',
release_tracks != null,
);
let nums = Object.values(release_tracks ?? {}).map(it => it.highest) ?? [];
return new Set(nums);
}

hasOwnerUser(userId) {
Expand Down Expand Up @@ -130,8 +147,19 @@ export default class Crate extends Model {
return [...(teams ?? []), ...(users ?? [])];
});

loadVersionsTask = task(async () => {
return (await this.versions) ?? [];
/**
* @param {Object} [Options]
* @param {bool} [Options.reload]
* @param {bool} [Options.withReleaseTracks] - default: true if it has not yet been loaded else false
**/
loadVersionsTask = task(async ({ reload = false, withReleaseTracks } = {}) => {
let versionsRef = this.hasMany('versions');
let opts = { adapterOptions: { withReleaseTracks } };
if (opts.adapterOptions.withReleaseTracks == null) {
opts.adapterOptions.withReleaseTracks = this?.versions_meta?.release_tracks == null;
}
let fut = reload === true ? versionsRef.reload(opts) : versionsRef.load(opts);
return (await fut) ?? [];
});
Comment on lines +150 to 163
Copy link
Contributor Author

@eth3lbert eth3lbert Dec 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could also consider extending this to two tasks: a parent task to calculate the options, and a child task to perform the actual API request.

The reason for this is that for reload, we likely want to request with the same options. However, with a single task and no other state to hold the calculated options (withReleaseTracks), the value might change. If a child task is introduced, we could use last.args as calculated options.

}

Expand Down
2 changes: 1 addition & 1 deletion app/models/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export default class Version extends Model {
return false;
}

return this.crate?.releaseTrackSet.has(this.id);
return this.crate?.releaseTrackSet.has(this.num);
}

get featureList() {
Expand Down
2 changes: 1 addition & 1 deletion app/routes/crate/dependencies.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default class VersionRoute extends Route {

async model() {
let crate = this.modelFor('crate');
let versions = await crate.get('versions');
let versions = await crate.loadVersionsTask.perform();

let { default_version } = crate;
let version = versions.find(version => version.num === default_version) ?? versions.lastObject;
Expand Down
2 changes: 1 addition & 1 deletion app/routes/crate/range.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default class VersionRoute extends Route {
let crate = this.modelFor('crate');

try {
let versions = await crate.hasMany('versions').load();
let versions = await crate.loadVersionsTask.perform();
let allVersionNums = versions.map(it => it.num);
let unyankedVersionNums = versions.filter(it => !it.yanked).map(it => it.num);

Expand Down
2 changes: 1 addition & 1 deletion app/routes/crate/version-dependencies.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default class VersionRoute extends Route {

let versions;
try {
versions = await crate.get('versions');
versions = await crate.loadVersionsTask.perform();
} catch (error) {
let title = `${crate.name}: Failed to load version data`;
return this.router.replaceWith('catch-all', { transition, error, title, tryAgain: true });
Expand Down
2 changes: 1 addition & 1 deletion app/routes/crate/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export default class VersionRoute extends Route {

let versions;
try {
versions = await crate.get('versions');
versions = await crate.loadVersionsTask.perform();
} catch (error) {
let title = `${crate.name}: Failed to load version data`;
return this.router.replaceWith('catch-all', { transition, error, title, tryAgain: true });
Expand Down
7 changes: 4 additions & 3 deletions e2e/acceptance/crate-dependencies.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,17 @@ test.describe('Acceptance | crate dependencies page', { tag: '@acceptance' }, ()
});

await ember.addHook(async owner => {
// Load `crate` and then explicitly unload the side-loaded `versions`.
// Load `crate` and ensure all `versions` are not loaded.
let store = owner.lookup('service:store');
let crateRecord = await store.findRecord('crate', 'foo');
let versions = crateRecord.hasMany('versions').value();
versions.forEach(record => record.unloadRecord());
let versionsRef = crateRecord.hasMany('versions');
globalThis.version_ids = versionsRef.ids();
});

await page.goto('/crates/foo/1.0.0/dependencies');

await expect(page).toHaveURL('/crates/foo/1.0.0/dependencies');
await page.waitForFunction(() => globalThis.version_ids?.length === 0);
await expect(page.locator('[data-test-404-page]')).toBeVisible();
await expect(page.locator('[data-test-title]')).toHaveText('foo: Failed to load version data');
await expect(page.locator('[data-test-go-back]')).toHaveCount(0);
Expand Down
1 change: 1 addition & 0 deletions e2e/acceptance/versions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ test.describe('Acceptance | crate versions page', { tag: '@acceptance' }, () =>
await page.goto('/crates/nanomsg/versions');
await expect(page).toHaveURL('/crates/nanomsg/versions');

await expect(page.locator('[data-test-version]')).toHaveCount(4);
let versions = await page.locator('[data-test-version]').evaluateAll(el => el.map(it => it.dataset.testVersion));
expect(versions).toEqual(['0.2.1', '0.3.0', '0.2.0', '0.1.0']);

Expand Down
7 changes: 4 additions & 3 deletions e2e/routes/crate/range.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,16 @@ test.describe('Route | crate.range', { tag: '@routes' }, () => {
});

await ember.addHook(async owner => {
// Load `crate` and then explicitly unload the side-loaded `versions`.
// Load `crate` and ensure all `versions` are not loaded.
let store = owner.lookup('service:store');
let crateRecord = await store.findRecord('crate', 'foo');
let versions = crateRecord.hasMany('versions').value();
versions.forEach(record => record.unloadRecord());
let versionsRef = crateRecord.hasMany('versions');
globalThis.version_ids = versionsRef.ids();
});

await page.goto('/crates/foo/range/^3');
await expect(page).toHaveURL('/crates/foo/range/%5E3');
await page.waitForFunction(() => globalThis.version_ids?.length === 0);
await expect(page.locator('[data-test-404-page]')).toBeVisible();
await expect(page.locator('[data-test-title]')).toHaveText('foo: Failed to load version data');
await expect(page.locator('[data-test-go-back]')).toHaveCount(0);
Expand Down
16 changes: 16 additions & 0 deletions mirage/route-handlers/-utils.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Response } from 'miragejs';
import semverParse from 'semver/functions/parse';
import semverSort from 'semver/functions/rsort';

export function notFound() {
return new Response(
Expand Down Expand Up @@ -31,3 +33,17 @@ export function compareIsoDates(a, b) {
let bDate = new Date(b);
return aDate < bDate ? -1 : aDate > bDate ? 1 : 0;
}

export function releaseTracks(versions) {
let versionNums = versions.models.filter(it => !it.yanked).map(it => it.num);
semverSort(versionNums, { loose: true });
let tracks = {};
for (let num of versionNums) {
let semver = semverParse(num, { loose: true });
if (!semver || semver.prerelease.length !== 0) continue;
let name = semver.major == 0 ? `0.${semver.minor}` : `${semver.major}`;
if (name in tracks) continue;
tracks[name] = { highest: num };
}
return tracks;
}
30 changes: 22 additions & 8 deletions mirage/route-handlers/crates.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Response } from 'miragejs';

import { getSession } from '../utils/session';
import { compareIsoDates, compareStrings, notFound, pageParams } from './-utils';
import { compareIsoDates, compareStrings, notFound, pageParams, releaseTracks } from './-utils';

export function list(schema, request) {
const { start, end } = pageParams(request);
Expand Down Expand Up @@ -58,12 +58,15 @@ export function register(server) {
let { name } = request.params;
let crate = schema.crates.findBy({ name });
if (!crate) return notFound();

let serialized = this.serialize(crate);
return {
...this.serialize(crate),
...this.serialize(crate.categories),
...this.serialize(crate.keywords),
...this.serialize(crate.versions.sort((a, b) => Number(b.id) - Number(a.id))),
categories: null,
keywords: null,
versions: null,
...serialized,
...(serialized.crate.categories && this.serialize(crate.categories)),
...(serialized.crate.keywords && this.serialize(crate.keywords)),
...(serialized.crate.versions && this.serialize(crate.versions.sort((a, b) => Number(b.id) - Number(a.id)))),
};
});

Expand Down Expand Up @@ -137,12 +140,23 @@ export function register(server) {
return { ok: true };
});

server.get('/api/v1/crates/:name/versions', (schema, request) => {
server.get('/api/v1/crates/:name/versions', function (schema, request) {
let { name } = request.params;
let crate = schema.crates.findBy({ name });
if (!crate) return notFound();

return crate.versions.sort((a, b) => compareIsoDates(b.created_at, a.created_at));
let versions = crate.versions.sort((a, b) => compareIsoDates(b.created_at, a.created_at));
let total = versions.length;
let include = request.queryParams?.include ?? '';
let release_tracks = include.split(',').includes('release_tracks') && releaseTracks(crate.versions);
let resp = {
...this.serialize(versions),
meta: { total, next_page: null },
};
if (release_tracks && Object.keys(release_tracks).length !== 0) {
resp.meta.release_tracks = release_tracks;
}
return resp;
});

server.get('/api/v1/crates/:name/:version_num/authors', (schema, request) => {
Expand Down
44 changes: 30 additions & 14 deletions mirage/serializers/crate.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,15 @@ import semverSort from 'semver/functions/rsort';
import { compareIsoDates } from '../route-handlers/-utils';
import BaseSerializer from './application';

const VALID_INCLUDE_MODEL = new Set(['versions', 'keywords', 'categories' /*, 'badges', 'downloads' */]);

export default BaseSerializer.extend({
include(request) {
let include = request.queryParams.include;
return include == null || include === 'full'
? VALID_INCLUDE_MODEL.values()
: include.split(',').filter(it => VALID_INCLUDE_MODEL.has(it));
},
attrs: [
'badges',
'categories',
Expand Down Expand Up @@ -38,19 +46,20 @@ export default BaseSerializer.extend({

getHashForResource() {
let [hash, addToIncludes] = BaseSerializer.prototype.getHashForResource.apply(this, arguments);
let includes = [...this.include(this.request)];

if (Array.isArray(hash)) {
for (let resource of hash) {
this._adjust(resource);
this._adjust(resource, includes);
}
} else {
this._adjust(hash);
this._adjust(hash, includes);
}

return [hash, addToIncludes];
},

_adjust(hash) {
_adjust(hash, includes) {
let versions = this.schema.versions.where({ crateId: hash.id });
assert(`crate \`${hash.name}\` has no associated versions`, versions.length !== 0);

Expand All @@ -63,25 +72,32 @@ export default BaseSerializer.extend({
versionNums[0];
hash.yanked = versionsByNum[hash.default_version]?.yanked ?? false;

versions = versions.filter(it => !it.yanked);
versionNums = versionNums.filter(it => !versionsByNum[it].yanked);
hash.max_version = versionNums[0] ?? '0.0.0';
hash.max_stable_version = versionNums.find(it => !prerelease(it, { loose: true })) ?? null;
if (includes.includes('versions')) {
versions = versions.filter(it => !it.yanked);
versionNums = versionNums.filter(it => !versionsByNum[it].yanked);
hash.max_version = versionNums[0] ?? '0.0.0';
hash.max_stable_version = versionNums.find(it => !prerelease(it, { loose: true })) ?? null;

let newestVersions = versions.models.sort((a, b) => compareIsoDates(b.updated_at, a.updated_at));
hash.newest_version = newestVersions[0]?.num ?? '0.0.0';
let newestVersions = versions.models.sort((a, b) => compareIsoDates(b.updated_at, a.updated_at));
hash.newest_version = newestVersions[0]?.num ?? '0.0.0';

hash.versions = hash.version_ids;
} else {
hash.max_version = '0.0.0';
hash.newest_version = '0.0.0';
hash.max_stable_version = null;
hash.versions = null;
}
delete hash.version_ids;

hash.id = hash.name;

hash.categories = hash.category_ids;
hash.categories = includes.includes('categories') ? hash.category_ids : null;
delete hash.category_ids;

hash.keywords = hash.keyword_ids;
hash.keywords = includes.includes('keywords') ? hash.keyword_ids : null;
delete hash.keyword_ids;

hash.versions = hash.version_ids;
delete hash.version_ids;

delete hash.team_owner_ids;
delete hash.user_owner_ids;
},
Expand Down
6 changes: 3 additions & 3 deletions tests/acceptance/crate-dependencies-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ module('Acceptance | crate dependencies page', function (hooks) {

this.server.get('/api/v1/crates/:crate_name/versions', {}, 500);

// Load `crate` and then explicitly unload the side-loaded `versions`.
// Load `crate` and ensure all `versions` are not loaded.
let store = this.owner.lookup('service:store');
let crateRecord = await store.findRecord('crate', 'foo');
let versions = crateRecord.hasMany('versions').value();
versions.forEach(record => record.unloadRecord());
let versionsRef = crateRecord.hasMany('versions');
assert.deepEqual(versionsRef.ids(), []);

await visit('/crates/foo/1.0.0/dependencies');
assert.strictEqual(currentURL(), '/crates/foo/1.0.0/dependencies');
Expand Down
Loading
Loading